diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..0fa58e2 Binary files /dev/null and b/.coverage differ diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..4e0225d --- /dev/null +++ b/.flake8 @@ -0,0 +1,16 @@ +[flake8] +max-line-length = 120 +extend-ignore = E203, W503 +exclude = + .git, + __pycache__, + .venv, + venv, + build, + dist, + *.egg-info, + .pytest_cache, + .mypy_cache +per-file-ignores = + # Functional tests import after path manipulation + tests/functional/*.py: E402 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fa98ec7..5e02674 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,14 +1,24 @@ name: Publish Python Package -# 1. Trigger the workflow when a new GitHub Release is published +# Trigger on: +# - pushes to any branch (we'll publish to TestPyPI for non-main branches and to PyPI for main) +# - published GitHub Releases (keep existing behavior for canonical releases) on: + push: + branches: ["**"] release: types: [published] +permissions: + contents: read + id-token: write + jobs: build_sdist_and_wheel: name: Build distribution 📦 runs-on: ubuntu-latest + outputs: + dist-path: ${{ steps.upload.outputs.artifact-path }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -18,70 +28,69 @@ jobs: with: python-version: "3.x" - # Install the 'build' tool, which creates the distribution files - - name: Install build dependencies - run: python -m pip install build + - name: Install build tooling + run: python -m pip install --upgrade pip build - # Create source and wheel distribution archives in the 'dist/' directory - name: Build distributions - run: python -m build + run: python -m build --sdist --wheel - # Store the built artifacts for the next jobs to download - name: Upload distribution artifacts + id: upload uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ - # --- PyPI Release Job (Secure, using OIDC) --- - pypi_publish: - name: Publish to PyPI - needs: [build_sdist_and_wheel] # Ensure the build job succeeds first + # Publish from pushes to non-main branches -> TestPyPI + publish_testpypi: + name: Publish to TestPyPI (branches except main) runs-on: ubuntu-latest - # Only publish to PyPI for full releases (not pre-releases) - if: github.event.release.prerelease == false - - # 2. Use the environment name you configured in PyPI (Optional, but recommended) - environment: pypi - - # 3. CRITICAL: This is the mandatory permission for Trusted Publishers (OIDC) + needs: build_sdist_and_wheel + # Only run for pushes (not releases) and only for branches that are NOT main + if: | + github.event_name == 'push' && + startsWith(github.ref, 'refs/heads/') && + github.ref != 'refs/heads/main' + environment: testpypi permissions: - id-token: write - contents: read # Required to read the repository contents + id-token: write + contents: read steps: - - name: Download all the dists + - name: Download dists uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - # 4. The PyPA action handles the OIDC token exchange and Twine upload securely - - name: Publish package distributions to PyPI + - name: Publish package distributions to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 - - # --- TestPyPI Release Job (Optional, for pre-release testing) --- - testpypi_publish: - name: Publish to TestPyPI - needs: [build_sdist_and_wheel] - runs-on: ubuntu-latest - - # Use the environment name you configured in TestPyPI - environment: testpypi + with: + # repository-url directs upload to TestPyPI + repository-url: https://test.pypi.org/legacy/ + # Publish from pushes to main OR when a Release is published -> Production PyPI + publish_pypi: + name: Publish to PyPI (main branch or GitHub Release) + runs-on: ubuntu-latest + needs: build_sdist_and_wheel + # Run when: + # - push to main branch, or + # - release published event (keeps existing release-based behavior) + if: | + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'release' && github.event.action == 'published') + environment: pypi permissions: id-token: write contents: read steps: - - name: Download all the dists + - name: Download dists uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - - # The 'repository-url' is what makes this target TestPyPI - - name: Publish package distributions to TestPyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ \ No newline at end of file + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..554353e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,85 @@ +name: Tests + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + lint: + name: Lint and Type Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run flake8 + run: | + python -m flake8 fmd_api/ tests/ --count --show-source --statistics + + - name: Run mypy + run: | + python -m mypy fmd_api/ + + test: + name: Test Python ${{ matrix.python-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run unit tests with coverage + run: | + python -m pytest tests/unit --cov=fmd_api --cov-branch --cov-report=xml --cov-report=term -v + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./coverage.xml + flags: unittests + name: codecov-${{ matrix.os }}-py${{ matrix.python-version }} + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + + - name: Run functional tests (if credentials available) + # Skip on PRs from forks where secrets aren't available + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + continue-on-error: true + run: | + python -m pytest tests/functional -v + env: + # Optional: set up test credentials as repository secrets + FMD_BASE_URL: ${{ secrets.FMD_BASE_URL }} + FMD_ID: ${{ secrets.FMD_ID }} + FMD_PASSWORD: ${{ secrets.FMD_PASSWORD }} diff --git a/.gitignore b/.gitignore index e84e035..934a81c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ __pycache__/ *.pyc *.pyo *.pyd +*.zip + +# User configuration files +*.env +credentials.txt +*.jpg # C extensions *.so @@ -39,4 +45,7 @@ env/ # FMD Server and android app files fmd-server/ -fmd-android/ \ No newline at end of file +fmd-android/ + +#credentials file +examples/tests/credentials.txt \ No newline at end of file diff --git a/README.md b/README.md index 48bf5d4..d06d5d5 100644 --- a/README.md +++ b/README.md @@ -1,318 +1,174 @@ -# fmd_api: Python client for interacting with FMD (fmd-foss.org) +# fmd_api: Python client for FMD (Find My Device) -This directory contains Python scripts for interacting with an FMD (Find My Device) server, including authentication, key retrieval, and location data decryption. -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. -In this repo you'll find fmd_api.py is the tool supporting fmd_client.py, used in most of the examples. +[![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) +[![codecov](https://codecov.io/gh/devinslick/fmd_api/branch/main/graph/badge.svg?token=8WA2TKXIOW)](https://codecov.io/gh/devinslick/fmd_api) -## Prerequisites -- Python 3.7+ -- Install dependencies: - ``` - pip install requests argon2-cffi cryptography - ``` - -## Scripts Overview - -### Main Client - -#### `fmd_client.py` -**The primary tool for bulk data export.** Downloads locations and/or pictures, saving them to a directory or ZIP archive. - -**Usage:** -```bash -python fmd_client.py --url --id --password --output [--locations [N]] [--pictures [N]] -``` - -**Options:** -- `--locations [N]`: Export all locations, or specify N for the most recent N locations -- `--pictures [N]`: Export all pictures, or specify N for the most recent N pictures -- `--output`: Output directory or `.zip` file path -- `--session`: Session duration in seconds (default: 3600) - -**Examples:** -```bash -# Export all locations to CSV -python fmd_client.py --url https://fmd.example.com --id alice --password secret --output data --locations - -# Export last 10 locations and 5 pictures to ZIP -python fmd_client.py --url https://fmd.example.com --id alice --password secret --output export.zip --locations 10 --pictures 5 -``` - -### Debugging Scripts +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. -Located in `debugging/`, these scripts help test individual workflows and troubleshoot issues. +## Install -#### `fmd_get_location.py` -**End-to-end test:** Authenticates, retrieves, and decrypts the latest location in one step. - -**Usage:** -```bash -cd debugging -python fmd_get_location.py --url --id --password -``` - -#### `fmd_export_data.py` -**Test native export:** Downloads the server's pre-packaged export ZIP (if available). - -**Usage:** -```bash -cd debugging -python fmd_export_data.py --url --id --password --output export.zip -``` +- Requires Python 3.8+ +- Stable (PyPI): + ```bash + pip install fmd_api + ``` +- Pre‑release (Test PyPI): + ```bash + pip install --pre --index-url https://test.pypi.org/simple/ \ + --extra-index-url https://pypi.org/simple/ fmd_api + ``` -#### `request_location_example.py` -**Request new location:** Triggers a device to capture and upload a new location update. +## Quickstart -**Usage:** -```bash -cd debugging -python request_location_example.py --url --id --password [--provider all|gps|cell|last] [--wait SECONDS] -``` - -**Options:** -- `--provider`: Location provider to use (default: all) - - `all`: Use all available providers (GPS, network, fused) - - `gps`: GPS only (most accurate, slower) - - `cell`: Cellular network (faster, less accurate) - - `last`: Don't request new location, just get last known -- `--wait`: Seconds to wait for location update (default: 30) +```python +import asyncio, json +from fmd_api import FmdClient -**Example:** -```bash -# Request GPS location and wait 45 seconds -python request_location_example.py --url https://fmd.example.com --id alice --password secret --provider gps --wait 45 +async def main(): + # Recommended: async context manager auto-closes session + async with await FmdClient.create("https://fmd.example.com", "alice", "secret") as client: + # Request a fresh GPS fix and wait a bit on your side + await client.request_location("gps") -# Quick cellular network location -python request_location_example.py --url https://fmd.example.com --id alice --password secret --provider cell --wait 20 -``` + # Fetch most recent locations and decrypt the latest + blobs = await client.get_locations(num_to_get=1) + loc = json.loads(client.decrypt_data_blob(blobs[0])) + print(loc["lat"], loc["lon"], loc.get("accuracy")) -#### `diagnose_blob.py` -**Diagnostic tool:** Analyzes encrypted blob structure to troubleshoot decryption issues. + # Take a picture (validated helper) + await client.take_picture("front") -**Usage:** -```bash -cd debugging -python diagnose_blob.py --url --id --password +asyncio.run(main()) ``` -Shows: -- Private key size and type -- Actual blob size vs. expected structure -- Analysis of RSA session key packet layout -- First/last bytes in hex for inspection +### TLS and self-signed certificates -## Core Library +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: -### `fmd_api.py` -The foundational API library providing the `FmdApi` class. Handles: -- Authentication (salt retrieval, Argon2id password hashing, token management) -- Encrypted private key retrieval and decryption -- Data blob decryption (RSA-OAEP + AES-GCM) -- Location and picture retrieval -- Command sending (request location updates, ring, lock, camera) - - Commands are cryptographically signed using RSA-PSS to prove authenticity +- Preferred (secure): provide a custom SSLContext that trusts your CA or certificate +- Last resort (not for production): disable certificate validation explicitly -**For application developers:** See [LOCATION_FIELDS.md](LOCATION_FIELDS.md) for detailed documentation on extracting and using accuracy, altitude, speed, and heading fields. +Examples: -**Quick example:** ```python -import asyncio -import json -from fmd_api import FmdApi +import ssl +from fmd_api import FmdClient -async def main(): - # Authenticate (automatically retrieves and decrypts private key) - api = await FmdApi.create("https://fmd.example.com", "alice", "secret") - - # Request a new location update - await api.request_location('gps') # or 'all', 'cell', 'last' - await asyncio.sleep(30) # Wait for device to respond - - # Get locations - locations = await api.get_all_locations(num_to_get=10) # Last 10, or -1 for all - - # Decrypt a location blob - decrypted_data = api.decrypt_data_blob(locations[0]) - location = json.loads(decrypted_data) - - # Access fields (use .get() for optional fields) - lat = location['lat'] - lon = location['lon'] - speed = location.get('speed') # Optional, only when moving - heading = location.get('heading') # Optional, only when moving - - # Send commands (see Available Commands section below) - await api.send_command('ring') # Make device ring - await api.send_command('bluetooth on') # Enable Bluetooth - await api.send_command('camera front') # Take picture with front camera - -asyncio.run(main()) -``` +# 1) Custom CA bundle / pinned cert (recommended) +ctx = ssl.create_default_context() +ctx.load_verify_locations(cafile="/path/to/your/ca.pem") -### Available Commands +# Via constructor +client = FmdClient("https://fmd.example.com", ssl=ctx) -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: +# Or via factory +# async with await FmdClient.create("https://fmd.example.com", "user", "pass", ssl=ctx) as client: -#### Location Requests -```python -# Using convenience method -await api.request_location('gps') # GPS only -await api.request_location('all') # All providers (default) -await api.request_location('cell') # Cellular network only - -# Using send_command directly -await api.send_command('locate gps') -await api.send_command('locate') -await api.send_command('locate cell') -await api.send_command('locate last') # Last known, no new request - -# Using constants -from fmd_api import FmdCommands -await api.send_command(FmdCommands.LOCATE_GPS) +# 2) Disable verification (development only) +insecure_client = FmdClient("https://fmd.example.com", ssl=False) ``` -#### Device Control -```python -# Ring device -await api.send_command('ring') -await api.send_command(FmdCommands.RING) +Notes: +- HTTP (http://) is rejected. Use only HTTPS URLs. +- Prefer a custom SSLContext over disabling verification. +- For higher security, consider pinning the server cert in your context. -# Lock device screen -await api.send_command('lock') -await api.send_command(FmdCommands.LOCK) +> Warning +> +> 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. -# ⚠️ Delete/wipe device (DESTRUCTIVE - factory reset!) -await api.send_command('delete') -await api.send_command(FmdCommands.DELETE) -``` +#### Pinning the exact server certificate (recommended for self-signed) + +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. -#### Camera ```python -# Using convenience method -await api.take_picture('back') # Rear camera (default) -await api.take_picture('front') # Front camera (selfie) +import ssl +from fmd_api import FmdClient -# Using send_command -await api.send_command('camera back') -await api.send_command('camera front') +# Export your server's certificate to PEM (e.g., server-cert.pem) +ctx = ssl.create_default_context() +ctx.verify_mode = ssl.CERT_REQUIRED +ctx.check_hostname = True # keep hostname verification when possible +ctx.load_verify_locations(cafile="/path/to/server-cert.pem") -# Using constants -await api.send_command(FmdCommands.CAMERA_BACK) -await api.send_command(FmdCommands.CAMERA_FRONT) +client = FmdClient("https://fmd.example.com", ssl=ctx) +# async with await FmdClient.create("https://fmd.example.com", "user", "pass", ssl=ctx) as client: ``` -#### Bluetooth -```python -# Using convenience method -await api.toggle_bluetooth(True) # Enable -await api.toggle_bluetooth(False) # Disable +Tips: +- If the server cert changes, pinning will fail until you update the PEM. +- For intermediate/CA signing chains, prefer pinning a private CA instead of the leaf. -# Using send_command -await api.send_command('bluetooth on') -await api.send_command('bluetooth off') +## What’s in the box -# Using constants -await api.send_command(FmdCommands.BLUETOOTH_ON) -await api.send_command(FmdCommands.BLUETOOTH_OFF) -``` +- `FmdClient` (primary API) + - Auth and key retrieval (salt → Argon2id → access token → private key decrypt) + - Decrypt blobs (RSA‑OAEP wrapped AES‑GCM) + - Fetch data: `get_locations`, `get_pictures` + - Export: `export_data_zip(out_path)` — client-side packaging of all locations/pictures into ZIP (mimics web UI, no server endpoint) + - Validated command helpers: + - `request_location("all|gps|cell|last")` + - `take_picture("front|back")` + - `set_bluetooth(enable: bool)` — True = on, False = off + - `set_do_not_disturb(enable: bool)` — True = on, False = off + - `set_ringer_mode("normal|vibrate|silent")` + - `get_device_stats()` -**Note:** Android 12+ requires BLUETOOTH_CONNECT permission. + + - Low‑level: `decrypt_data_blob(b64_blob)` -#### Do Not Disturb Mode -```python -# Using convenience method -await api.toggle_do_not_disturb(True) # Enable DND -await api.toggle_do_not_disturb(False) # Disable DND +- `Device` helper (per‑device convenience) + - `await device.refresh()` → hydrate cached state + - `await device.get_location()` → parsed last location + - `await device.fetch_pictures(n)` + `await device.download_photo(item)` -# Using send_command -await api.send_command('nodisturb on') -await api.send_command('nodisturb off') +## Testing -# Using constants -await api.send_command(FmdCommands.NODISTURB_ON) -await api.send_command(FmdCommands.NODISTURB_OFF) -``` +### Functional tests -**Note:** Requires Do Not Disturb Access permission. +Runnable scripts under `tests/functional/`: -#### Ringer Mode -```python -# Using convenience method -await api.set_ringer_mode('normal') # Sound + vibrate -await api.set_ringer_mode('vibrate') # Vibrate only -await api.set_ringer_mode('silent') # Silent (also enables DND) - -# Using send_command -await api.send_command('ringermode normal') -await api.send_command('ringermode vibrate') -await api.send_command('ringermode silent') - -# Using constants -await api.send_command(FmdCommands.RINGERMODE_NORMAL) -await api.send_command(FmdCommands.RINGERMODE_VIBRATE) -await api.send_command(FmdCommands.RINGERMODE_SILENT) -``` +- `test_auth.py` – basic auth smoke test +- `test_locations.py` – list and decrypt recent locations +- `test_pictures.py` – list and download/decrypt a photo +- `test_device.py` – device helper flows +- `test_commands.py` – validated command wrappers (no raw strings) +- `test_export.py` – export data to ZIP +- `test_request_location.py` – request location and poll for results -**Note:** Setting to "silent" also enables Do Not Disturb (Android behavior). Requires Do Not Disturb Access permission. +Put credentials in `tests/utils/credentials.txt` (copy from `credentials.txt.example`). -#### Device Information -```python -# Get network statistics (IP addresses, WiFi SSID/BSSID) -await api.get_device_stats() -await api.send_command('stats') -await api.send_command(FmdCommands.STATS) - -# Get battery and GPS status -await api.send_command('gps') -await api.send_command(FmdCommands.GPS) -``` +### Unit tests -**Note:** `stats` command requires Location permission to access WiFi information. +Located in `tests/unit/`: +- `test_client.py` – client HTTP flows with mocked responses +- `test_device.py` – device wrapper logic -#### Command Testing Script -Test any command easily: +Run with pytest: ```bash -cd debugging -python test_command.py --url --id --password - -# Examples -python test_command.py "ring" --url https://fmd.example.com --id alice --password secret -python test_command.py "bluetooth on" --url https://fmd.example.com --id alice --password secret -python test_command.py "ringermode vibrate" --url https://fmd.example.com --id alice --password secret +pip install -e ".[dev]" +pytest tests/unit/ ``` -## Troubleshooting - -### Empty or Invalid Blobs -If you see warnings like `"Blob too small for decryption"`, the server returned empty/corrupted data. This can happen when: -- No location data was uploaded for that time period -- Data was deleted or corrupted server-side -- The server returns placeholder values for missing data +## API highlights -The client will skip these automatically and report the count at the end. +- Encryption compatible with FMD web client + - RSA‑3072 OAEP (SHA‑256) wrapping AES‑GCM session key + - AES‑GCM IV: 12 bytes; RSA packet size: 384 bytes +- Password/key derivation with Argon2id +- Robust HTTP JSON/text fallback and 401 re‑auth -### Debugging Decryption Issues -Use `debugging/diagnose_blob.py` to analyze blob structure: -```bash -cd debugging -python diagnose_blob.py --url --id --password -``` - -This shows the actual blob size, expected structure, and helps identify if the RSA key size or encryption format has changed. +## Troubleshooting -## Notes -- All scripts use Argon2id password hashing and AES-GCM/RSA-OAEP encryption, matching the FMD web client -- Blobs must be at least 396 bytes (384 RSA session key + 12 IV + ciphertext) to be valid -- Base64 data from the server may be missing padding - use `_pad_base64()` helper when needed -- **Location data fields**: - - Always present: `time`, `provider`, `bat` (battery %), `lat`, `lon`, `date` (Unix ms) - - Optional (depending on provider): `accuracy` (meters), `altitude` (meters), `speed` (m/s), `heading` (degrees) -- Picture data is double-encoded: encrypted blob → base64 string → actual image bytes +- "Blob too small for decryption": server returned empty/placeholder data. Skip and continue. +- Pictures may be double‑encoded (encrypted blob → base64 image string). The examples show how to decode safely. ## Credits -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. +This client targets the FMD ecosystem: + +- https://fmd-foss.org/ +- https://gitlab.com/fmd-foss +- Public community instance: https://fmd.nulide.de/ -- **[fmd-foss.org](https://fmd-foss.org/)**: The official project website, offering general information, documentation, and news. -- **[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. -- **[fmd.nulide.de](https://fmd.nulide.de/)**: A generously hosted public instance of the FMD server available for community use. \ No newline at end of file +MIT © 2025 Devin Slick \ No newline at end of file diff --git a/coverage.xml b/coverage.xml new file mode 100644 index 0000000..f2f9dfa --- /dev/null +++ b/coverage.xml @@ -0,0 +1,549 @@ + + + + + + C:\Users\Devin\Repos\fmd_api\fmd_api + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/debugging/LOCATION_FIELDS.md b/debugging/LOCATION_FIELDS.md deleted file mode 100644 index 44aaed0..0000000 --- a/debugging/LOCATION_FIELDS.md +++ /dev/null @@ -1,182 +0,0 @@ -# FMD API Location Fields Reference - -Quick reference for application developers using `fmd_api.py` to work with location data. - -## Basic Usage - -```python -import asyncio -import json -from fmd_api import FmdApi - -async def main(): - api = await FmdApi.create('https://fmd.example.com', 'device-id', 'password') - blobs = await api.get_all_locations(10) - - for blob in blobs: - location = json.loads(api.decrypt_data_blob(blob)) - # Use location fields here (see below) - -asyncio.run(main()) -``` - -## Location Data Fields - -### Always Present - -| Field | Type | Description | Example | -|-------|------|-------------|---------| -| `time` | `str` | Human-readable timestamp | `"Sat Oct 18 14:08:20 CDT 2025"` | -| `date` | `int` | Unix timestamp (milliseconds) | `1760814500242` | -| `provider` | `str` | Location provider | `"gps"`, `"network"`, `"fused"`, `"BeaconDB"` | -| `bat` | `int` | Battery percentage | `78` (= 78%) | -| `lat` | `float` | Latitude | `32.8429147` | -| `lon` | `float` | Longitude | `-97.0714002` | - -### Optional (GPS/Movement-Dependent) - -| Field | Type | Description | Present When | Example | -|-------|------|-------------|--------------|---------| -| `accuracy` | `float` | GPS accuracy radius (meters) | GPS provider used | `13.793` | -| `altitude` | `float` | Altitude above sea level (meters) | GPS provider used | `150.3` | -| `speed` | `float` | Speed (meters/second) | Device is moving | `2.68` (= 9.6 km/h) | -| `heading` | `float` | Direction (degrees, 0-360) | Device moving with direction | `77.9` (= ENE) | - -## Code Examples - -### Example 1: Safe Field Access - -```python -# Always-present fields - use direct access -latitude = location['lat'] -longitude = location['lon'] -battery = location['bat'] - -# Optional fields - use .get() to avoid KeyError -accuracy = location.get('accuracy') # Returns None if not present -altitude = location.get('altitude') # Returns None if not present -speed = location.get('speed') # Returns None if not present -heading = location.get('heading') # Returns None if not present -``` - -### Example 2: Check if Device is Moving - -```python -speed = location.get('speed') -if speed is not None and speed > 1.0: # > 1 m/s = 3.6 km/h - print(f"Device is moving at {speed * 3.6:.1f} km/h") -else: - print("Device is stationary") -``` - -### Example 3: Convert Speed to Different Units - -```python -speed_ms = location.get('speed') -if speed_ms: - speed_kmh = speed_ms * 3.6 # kilometers/hour - speed_mph = speed_ms * 2.237 # miles/hour - speed_knots = speed_ms * 1.944 # knots - print(f"Speed: {speed_kmh:.1f} km/h ({speed_mph:.1f} mph)") -``` - -### Example 4: Convert Heading to Compass Direction - -```python -heading = location.get('heading') -if heading is not None: - directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", - "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] - idx = int((heading + 11.25) / 22.5) % 16 - compass = directions[idx] - print(f"Heading: {heading:.1f}° ({compass})") -``` - -### Example 5: Check Location Quality - -```python -accuracy = location.get('accuracy') -if accuracy is not None: - if accuracy < 20: - quality = "Excellent (< 20m)" - elif accuracy < 50: - quality = "Good (< 50m)" - elif accuracy < 100: - quality = "Fair (< 100m)" - else: - quality = f"Poor ({accuracy:.0f}m)" - print(f"GPS Quality: {quality}") -``` - -### Example 6: Generate Map Links - -```python -lat = location['lat'] -lon = location['lon'] - -# Google Maps -google_url = f"https://www.google.com/maps?q={lat},{lon}" - -# OpenStreetMap -osm_url = f"https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=16" - -# Apple Maps -apple_url = f"https://maps.apple.com/?ll={lat},{lon}" -``` - -### Example 7: Filter High-Speed Locations - -```python -# Find all locations where device was traveling > 50 km/h (13.9 m/s) -high_speed_locations = [] -for blob in await api.get_all_locations(): - location = json.loads(api.decrypt_data_blob(blob)) - speed = location.get('speed') - if speed and speed > 13.9: - high_speed_locations.append(location) - -print(f"Found {len(high_speed_locations)} high-speed locations") -``` - -## Common Pitfalls - -### ❌ Don't Do This (will crash if field missing): -```python -heading = location['heading'] # KeyError if not present! -``` - -### ✅ Do This Instead: -```python -heading = location.get('heading') # Returns None if not present -if heading is not None: - # Safe to use heading here - print(f"Heading: {heading}°") -``` - -### ❌ Don't Assume All Locations Have Speed: -```python -if location['speed'] > 5: # KeyError if stationary! - print("Fast!") -``` - -### ✅ Check if Speed Exists First: -```python -speed = location.get('speed') -if speed and speed > 5: - print("Fast!") -``` - -## Provider Characteristics - -Different providers include different optional fields: - -| Provider | Accuracy | Altitude | Speed | Heading | -|----------|----------|----------|-------|---------| -| `gps` | ✅ Yes | ✅ Yes | ✅ If moving | ✅ If moving | -| `fused` | ✅ Yes | ✅ Yes | ⚠️ Sometimes | ⚠️ Sometimes | -| `network` | ✅ Yes | ⚠️ Sometimes | ❌ Rare | ❌ Rare | -| `BeaconDB` | ✅ Yes | ❌ No | ❌ No | ❌ No | - -## Complete Working Example - -See `debugging/example_location_fields.py` for a complete, runnable example demonstrating all location field usage patterns. diff --git a/debugging/check_photo_exif.py b/debugging/check_photo_exif.py deleted file mode 100644 index 35b9a2f..0000000 --- a/debugging/check_photo_exif.py +++ /dev/null @@ -1,123 +0,0 @@ -""" -Check if FMD photos contain EXIF timestamp metadata. - -This script downloads a photo and examines its EXIF data to see -if the original capture timestamp is preserved. - -Usage: - python check_photo_exif.py - -Example: - python check_photo_exif.py https://fmd.example.com my-device-id mypassword -""" -import argparse -import asyncio -import base64 -import sys -from pathlib import Path - -# Add parent directory to path for imports -sys.path.insert(0, str(Path(__file__).parent.parent)) -from fmd_api import FmdApi - -async def check_photo_exif(fmd_url: str, device_id: str, password: str): - """Check if photos have EXIF timestamp data.""" - - print(f"Connecting to FMD server: {fmd_url}") - print(f"Device ID: {device_id}") - - api = await FmdApi.create(fmd_url, device_id, password) - - # Get one photo - print("\nFetching 1 most recent photo...") - pictures = await api.get_pictures(num_to_get=1) - - if not pictures: - print("No photos available on server") - return - - print(f"Found {len(pictures)} photo(s)") - - # Decrypt the first photo - blob = pictures[0] - decrypted = api.decrypt_data_blob(blob) - image_bytes = base64.b64decode(decrypted) - - print(f"\nImage size: {len(image_bytes)} bytes") - - # Check for JPEG markers - if image_bytes[:2] == b'\xff\xd8': - print("✓ Valid JPEG file detected") - else: - print("✗ Not a valid JPEG file") - return - - # Try to read EXIF data - try: - from PIL import Image - from PIL.ExifTags import TAGS - import io - - img = Image.open(io.BytesIO(image_bytes)) - - # Get EXIF data - exif_data = img._getexif() - - if exif_data: - print("\n✓ EXIF data found!") - print("\nRelevant EXIF tags:") - - # Look for timestamp-related tags - timestamp_tags = { - 'DateTime': 306, - 'DateTimeOriginal': 36867, - 'DateTimeDigitized': 36868, - } - - found_timestamps = False - for tag_name, tag_id in timestamp_tags.items(): - if tag_id in exif_data: - value = exif_data[tag_id] - print(f" {tag_name}: {value}") - found_timestamps = True - - if not found_timestamps: - print(" No timestamp tags found in EXIF data") - - # Show all EXIF tags for debugging - print("\nAll EXIF tags:") - for tag_id, value in exif_data.items(): - tag_name = TAGS.get(tag_id, tag_id) - print(f" {tag_name} ({tag_id}): {value}") - else: - print("\n✗ No EXIF data found in image") - - except ImportError: - print("\n⚠ PIL/Pillow not installed. Install with: pip install pillow") - print("Cannot check EXIF data without PIL") - except Exception as e: - print(f"\n✗ Error reading EXIF data: {e}") - - # FmdApi doesn't have a close method, no cleanup needed - -def main(): - """Parse command line arguments and run the check.""" - parser = argparse.ArgumentParser( - description='Check if FMD photos contain EXIF timestamp metadata', - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python check_photo_exif.py https://fmd.example.com my-device-id mypassword - python check_photo_exif.py https://192.168.1.100:8080 phone-1 secret123 - """ - ) - parser.add_argument('fmd_url', help='FMD server URL (e.g., https://fmd.example.com)') - parser.add_argument('device_id', help='Device ID registered with FMD server') - parser.add_argument('password', help='Device password') - - args = parser.parse_args() - - asyncio.run(check_photo_exif(args.fmd_url, args.device_id, args.password)) - -if __name__ == "__main__": - main() diff --git a/debugging/command_examples.py b/debugging/command_examples.py deleted file mode 100644 index 1c6e4d7..0000000 --- a/debugging/command_examples.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python3 -""" -Comprehensive examples demonstrating all FMD device commands. - -This script shows how to use each command type with the FmdApi class, -including string commands, constants, and convenience methods. - -Usage: - python command_examples.py --url --id --password -""" -import argparse -import asyncio -import sys -import os - -# Add parent directory to path to import fmd_api -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from fmd_api import FmdApi, FmdCommands - - -async def demonstrate_location_commands(api): - """Demonstrate all location request variations.""" - print("\n=== Location Commands ===") - - # Using convenience method (RECOMMENDED) - print("1. Request location with all providers (convenience method):") - await api.request_location('all') - - print("2. Request GPS-only location:") - await api.request_location('gps') - - print("3. Request cellular network location:") - await api.request_location('cell') - - # Using constants - print("4. Using FmdCommands constants:") - await api.send_command(FmdCommands.LOCATE_GPS) - - # Using raw strings - print("5. Using raw command strings:") - await api.send_command('locate') - - -async def demonstrate_device_control(api): - """Demonstrate device control commands.""" - print("\n=== Device Control Commands ===") - - # Ring - print("1. Ring device (full volume, ignores DND):") - await api.send_command(FmdCommands.RING) - # Alternative: await api.send_command('ring') - - # Lock - print("2. Lock device screen:") - await api.send_command(FmdCommands.LOCK) - # Alternative: await api.send_command('lock') - - # Delete (COMMENTED OUT - DESTRUCTIVE!) - print("3. Delete/wipe device (DESTRUCTIVE - NOT EXECUTED):") - print(" # await api.send_command(FmdCommands.DELETE)") - print(" # await api.send_command('delete')") - - -async def demonstrate_camera_commands(api): - """Demonstrate camera commands.""" - print("\n=== Camera Commands ===") - - # Using convenience method (RECOMMENDED) - print("1. Take picture with rear camera (convenience method):") - await api.take_picture('back') - - print("2. Take picture with front camera:") - await api.take_picture('front') - - # Using constants - print("3. Using FmdCommands constants:") - await api.send_command(FmdCommands.CAMERA_BACK) - await api.send_command(FmdCommands.CAMERA_FRONT) - - # Using raw strings - print("4. Using raw command strings:") - await api.send_command('camera back') - await api.send_command('camera front') - - -async def demonstrate_bluetooth_commands(api): - """Demonstrate Bluetooth control.""" - print("\n=== Bluetooth Commands ===") - print("Note: Android 12+ requires BLUETOOTH_CONNECT permission") - - # Using convenience method (RECOMMENDED) - print("1. Enable Bluetooth (convenience method):") - await api.toggle_bluetooth(True) - - print("2. Disable Bluetooth:") - await api.toggle_bluetooth(False) - - # Using constants - print("3. Using FmdCommands constants:") - await api.send_command(FmdCommands.BLUETOOTH_ON) - await api.send_command(FmdCommands.BLUETOOTH_OFF) - - # Using raw strings - print("4. Using raw command strings:") - await api.send_command('bluetooth on') - await api.send_command('bluetooth off') - - -async def demonstrate_dnd_commands(api): - """Demonstrate Do Not Disturb control.""" - print("\n=== Do Not Disturb Commands ===") - print("Note: Requires Do Not Disturb Access permission") - - # Using convenience method (RECOMMENDED) - print("1. Enable DND (convenience method):") - await api.toggle_do_not_disturb(True) - - print("2. Disable DND:") - await api.toggle_do_not_disturb(False) - - # Using constants - print("3. Using FmdCommands constants:") - await api.send_command(FmdCommands.NODISTURB_ON) - await api.send_command(FmdCommands.NODISTURB_OFF) - - # Using raw strings - print("4. Using raw command strings:") - await api.send_command('nodisturb on') - await api.send_command('nodisturb off') - - -async def demonstrate_ringer_mode_commands(api): - """Demonstrate ringer mode control.""" - print("\n=== Ringer Mode Commands ===") - print("Note: 'silent' mode also enables DND (Android behavior)") - print(" Requires Do Not Disturb Access permission") - - # Using convenience method (RECOMMENDED) - print("1. Set to normal mode (sound + vibrate) - convenience method:") - await api.set_ringer_mode('normal') - - print("2. Set to vibrate mode:") - await api.set_ringer_mode('vibrate') - - print("3. Set to silent mode (also enables DND):") - await api.set_ringer_mode('silent') - - # Using constants - print("4. Using FmdCommands constants:") - await api.send_command(FmdCommands.RINGERMODE_NORMAL) - await api.send_command(FmdCommands.RINGERMODE_VIBRATE) - await api.send_command(FmdCommands.RINGERMODE_SILENT) - - # Using raw strings - print("5. Using raw command strings:") - await api.send_command('ringermode normal') - await api.send_command('ringermode vibrate') - await api.send_command('ringermode silent') - - -async def demonstrate_info_commands(api): - """Demonstrate information/status commands.""" - print("\n=== Information/Status Commands ===") - - # Using convenience method (RECOMMENDED) - print("1. Get network statistics (convenience method):") - await api.get_device_stats() - - # Using constants - print("2. Using FmdCommands constants:") - await api.send_command(FmdCommands.STATS) - await api.send_command(FmdCommands.GPS) - - # Using raw strings - print("3. Using raw command strings:") - await api.send_command('stats') # IP addresses, WiFi SSID/BSSID - await api.send_command('gps') # Battery and GPS status - - -async def main(): - parser = argparse.ArgumentParser( - description='Comprehensive examples of all FMD device commands', - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - # Show all location command examples - python command_examples.py --url https://fmd.example.com --id alice --password secret - - # The script demonstrates but doesn't actually execute commands by default. - # Uncomment specific sections to test commands on your device. - """ - ) - parser.add_argument('--url', required=True, help='FMD server URL') - parser.add_argument('--id', required=True, help='FMD device ID') - parser.add_argument('--password', required=True, help='FMD password') - - args = parser.parse_args() - - print("FMD Command Examples") - print("=" * 60) - print(f"Server: {args.url}") - print(f"Device: {args.id}") - print() - print("NOTE: This script demonstrates command syntax but does NOT") - print(" actually execute commands. Uncomment sections below") - print(" to test specific commands on your device.") - print("=" * 60) - - # Authenticate - print("\nAuthenticating...") - api = await FmdApi.create(args.url, args.id, args.password) - print("✓ Authenticated successfully") - - # Demonstrate each command category - # UNCOMMENT the ones you want to actually test: - - # await demonstrate_location_commands(api) - # await demonstrate_device_control(api) - # await demonstrate_camera_commands(api) - # await demonstrate_bluetooth_commands(api) - # await demonstrate_dnd_commands(api) - # await demonstrate_ringer_mode_commands(api) - # await demonstrate_info_commands(api) - - print("\n" + "=" * 60) - print("Command Examples Complete") - print("=" * 60) - print("\nTo actually execute commands:") - print("1. Edit this script and uncomment the demonstrate_*() calls") - print("2. Or use test_command.py for individual command testing:") - print(" python test_command.py 'ring' --url --id --password ") - - -if __name__ == '__main__': - asyncio.run(main()) diff --git a/debugging/diagnose_blob.py b/debugging/diagnose_blob.py deleted file mode 100644 index c45e234..0000000 --- a/debugging/diagnose_blob.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -Diagnostic script to examine the structure of encrypted location blobs. -This will help identify if the blob format has changed. -""" -import argparse -import base64 -import sys -sys.path.insert(0, '..') -from fmd_api import FmdApi, _pad_base64 - -def main(): - parser = argparse.ArgumentParser(description="FMD Blob Structure Diagnostic") - parser.add_argument('--url', required=True, help='Base URL of the FMD server') - parser.add_argument('--id', required=True, help='FMD ID (username)') - parser.add_argument('--password', required=True, help='Password') - args = parser.parse_args() - - print("[*] Authenticating...") - api = FmdApi(args.url, args.id, args.password) - - print("[*] Retrieving private key info...") - # Check the private key size - from cryptography.hazmat.primitives import serialization - private_pem = api.private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - ) - print(f" Private key type: {type(api.private_key).__name__}") - print(f" Private key size: {api.private_key.key_size} bits ({api.private_key.key_size // 8} bytes)") - - print("\n[*] Downloading one location blob...") - location_blobs = api.get_all_locations(num_to_get=1) - - if not location_blobs: - print("No locations available!") - return - - blob_b64 = location_blobs[0] - print(f"\n[*] Blob analysis:") - print(f" Base64 length: {len(blob_b64)} characters") - - # Decode the blob - blob = base64.b64decode(_pad_base64(blob_b64)) - print(f" Raw blob length: {len(blob)} bytes") - - # Expected structure based on current code - RSA_KEY_SIZE_BYTES = 384 # 3072 bits / 8 - AES_GCM_IV_SIZE_BYTES = 12 - - print(f"\n[*] Expected structure (current code):") - print(f" RSA session key packet: {RSA_KEY_SIZE_BYTES} bytes (0-{RSA_KEY_SIZE_BYTES-1})") - print(f" AES-GCM IV: {AES_GCM_IV_SIZE_BYTES} bytes ({RSA_KEY_SIZE_BYTES}-{RSA_KEY_SIZE_BYTES+AES_GCM_IV_SIZE_BYTES-1})") - print(f" Ciphertext: {len(blob) - RSA_KEY_SIZE_BYTES - AES_GCM_IV_SIZE_BYTES} bytes (remaining)") - - # Try different RSA key sizes - possible_sizes = [256, 384, 512] # 2048-bit, 3072-bit, 4096-bit - print(f"\n[*] Testing possible RSA key sizes:") - for size in possible_sizes: - remaining = len(blob) - size - AES_GCM_IV_SIZE_BYTES - print(f" {size} bytes ({size*8} bits): IV at {size}-{size+AES_GCM_IV_SIZE_BYTES-1}, ciphertext {remaining} bytes") - if remaining > 0 and remaining < 500: # Location JSON is typically small - print(f" ^ This looks plausible for location data!") - - # Show first few bytes in hex - print(f"\n[*] First 32 bytes (hex): {blob[:32].hex()}") - print(f"[*] Last 32 bytes (hex): {blob[-32:].hex()}") - -if __name__ == "__main__": - main() diff --git a/debugging/example_location_fields.py b/debugging/example_location_fields.py deleted file mode 100644 index 6ebe710..0000000 --- a/debugging/example_location_fields.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python3 -""" -Example script demonstrating how to use fmd_api.py to extract and work with -all location data fields including accuracy, altitude, speed, and heading. - -This is a reference implementation for application developers integrating -the FMD API into their own applications. -""" -import sys -sys.path.insert(0, '..') -import asyncio -import json -from datetime import datetime -from fmd_api import FmdApi - - -async def main(): - """Demonstrates extracting and using all location data fields.""" - - # 1. Authenticate and create API client - print("Authenticating with FMD server...") - api = await FmdApi.create( - 'https://fmd.example.com', # Replace with your FMD server URL - 'your-device-id', # Replace with your device ID - 'your-password' # Replace with your password - ) - print("✓ Authenticated successfully\n") - - # 2. Fetch recent locations - print("Fetching 5 most recent locations...") - location_blobs = await api.get_all_locations(num_to_get=5) - print(f"✓ Retrieved {len(location_blobs)} location(s)\n") - - # 3. Process each location - for i, blob in enumerate(location_blobs, 1): - print(f"--- Location {i} ---") - - # Decrypt the blob - decrypted_bytes = api.decrypt_data_blob(blob) - location = json.loads(decrypted_bytes) - - # === ALWAYS-PRESENT FIELDS === - timestamp = location['time'] # "Sat Oct 18 14:08:20 CDT 2025" - date_ms = location['date'] # Unix timestamp in milliseconds - provider = location['provider'] # "gps", "network", "fused", "BeaconDB" - battery = location['bat'] # 0-100 - latitude = location['lat'] # degrees - longitude = location['lon'] # degrees - - print(f"Time: {timestamp}") - print(f"Provider: {provider}") - print(f"Battery: {battery}%") - print(f"Coordinates: ({latitude:.6f}, {longitude:.6f})") - - # === OPTIONAL FIELDS (use .get() to avoid KeyError) === - - # Accuracy (meters) - GPS uncertainty radius - accuracy = location.get('accuracy') - if accuracy is not None: - print(f"Accuracy: ±{accuracy:.1f} meters") - else: - print("Accuracy: Not available") - - # Altitude (meters above sea level) - altitude = location.get('altitude') - if altitude is not None: - print(f"Altitude: {altitude:.1f} meters") - else: - print("Altitude: Not available") - - # Speed (meters/second) - Only present when device is moving - speed = location.get('speed') - if speed is not None: - # Convert to different units - speed_kmh = speed * 3.6 - speed_mph = speed * 2.237 - print(f"Speed: {speed:.2f} m/s ({speed_kmh:.1f} km/h, {speed_mph:.1f} mph)") - else: - print("Speed: Not available (stationary or no GPS)") - - # Heading (degrees 0-360) - Compass direction of movement - heading = location.get('heading') - if heading is not None: - # Convert to compass direction - directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", - "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] - idx = int((heading + 11.25) / 22.5) % 16 - compass = directions[idx] - print(f"Heading: {heading:.1f}° ({compass})") - else: - print("Heading: Not available (stationary or no direction)") - - # === PRACTICAL EXAMPLES === - - # Example 1: Check if device is moving significantly - if speed is not None and speed > 1.0: # > 1 m/s = 3.6 km/h - print(f"🚶 Device is MOVING") - else: - print(f"🏠 Device is STATIONARY") - - # Example 2: Check location quality - if accuracy is not None: - if accuracy < 20: - quality = "Excellent" - elif accuracy < 50: - quality = "Good" - elif accuracy < 100: - quality = "Fair" - else: - quality = "Poor" - print(f"Location Quality: {quality}") - - # Example 3: Generate Google Maps link - maps_url = f"https://www.google.com/maps?q={latitude},{longitude}" - print(f"Map Link: {maps_url}") - - print() # Blank line between locations - - # 4. Advanced: Filter locations by criteria - print("\n--- Advanced: Finding High-Speed Locations ---") - all_locations = await api.get_all_locations(num_to_get=20) - - high_speed_count = 0 - for blob in all_locations: - try: - decrypted = api.decrypt_data_blob(blob) - loc = json.loads(decrypted) - speed = loc.get('speed') - - if speed is not None and speed > 5.0: # > 18 km/h (biking/driving) - high_speed_count += 1 - speed_kmh = speed * 3.6 - print(f" {loc['time']}: {speed_kmh:.1f} km/h") - except Exception as e: - print(f" Skipped invalid location: {e}") - - print(f"\nFound {high_speed_count} high-speed location(s) out of {len(all_locations)} total") - - -if __name__ == '__main__': - try: - asyncio.run(main()) - except KeyboardInterrupt: - print("\n\nInterrupted by user") - except Exception as e: - print(f"\nError: {e}") - sys.exit(1) diff --git a/debugging/fmd_export_data.py b/debugging/fmd_export_data.py deleted file mode 100644 index 99e14f6..0000000 --- a/debugging/fmd_export_data.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -FMD Server Export Data Script - -This script authenticates with the FMD server and downloads the exported data as a zip file using the 'export data' function. - -Usage: - python fmd_export_data.py --url --id --password --output - -Dependencies: - pip install requests argon2-cffi cryptography -""" -import argparse -from fmd_api import FmdApi - -def main(): - parser = argparse.ArgumentParser(description="FMD Server Export Data Script") - parser.add_argument('--url', required=True, help='Base URL of the FMD server (e.g. https://fmd.example.com)') - parser.add_argument('--id', required=True, help='FMD ID (username)') - parser.add_argument('--password', required=True, help='Password') - parser.add_argument('--output', required=True, help='Output zip file path') - parser.add_argument('--session', type=int, default=3600, help='Session duration in seconds (default: 3600)') - args = parser.parse_args() - - base_url = args.url.rstrip('/') - fmd_id = args.id - password = args.password - session_duration = args.session - output_file = args.output - - # Authenticate to get a valid session and access token - api = FmdApi(base_url, fmd_id, password, session_duration) - - print("[4] Downloading exported data...") - api.export_data_zip(output_file) - -if __name__ == "__main__": - main() diff --git a/debugging/fmd_get_location.py b/debugging/fmd_get_location.py deleted file mode 100644 index 2658c21..0000000 --- a/debugging/fmd_get_location.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -FMD Server End-to-End Location Retriever - -This script automates the full workflow: -- Authenticates with the FMD server using username and password -- Retrieves the encrypted private key and decrypts it -- Retrieves the latest location data and decrypts it -- Prints the decrypted location as JSON - -Usage: - python fmd_get_location.py --url --id --password - -Dependencies: - pip install aiohttp argon2-cffi cryptography -""" -import argparse -import asyncio -import json -import sys -import os - -# Add parent directory to path to import fmd_api -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -from fmd_api import FmdApi - -async def main(): - parser = argparse.ArgumentParser(description="FMD Server End-to-End Location Retriever") - parser.add_argument('--url', required=True, help='Base URL of the FMD server (e.g. https://fmd.example.com)') - parser.add_argument('--id', required=True, help='FMD ID (username)') - parser.add_argument('--password', required=True, help='Password') - parser.add_argument('--session', type=int, default=3600, help='Session duration in seconds (default: 3600)') - parser.add_argument('--num', type=int, default=1, help='Number of locations to fetch (default: 1)') - args = parser.parse_args() - - print("[1-3] Authenticating and retrieving keys...") - api = await FmdApi.create(args.url, args.id, args.password, args.session) - - print(f"[4] Downloading {args.num} latest location(s)...") - location_blobs = await api.get_all_locations(num_to_get=args.num, skip_empty=True) - - if not location_blobs: - print("No location data found!") - sys.exit(0) - - print(f"\n[5] Found {len(location_blobs)} location(s). Decrypting...") - for idx, blob in enumerate(location_blobs): - try: - plaintext = api.decrypt_data_blob(blob) - location_data = json.loads(plaintext) - print(f"\nLocation {idx + 1}:") - print(json.dumps(location_data, indent=2)) - except Exception as e: - print(f"Failed to decrypt location {idx + 1}: {e}") - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/debugging/pin_cert_example.py b/debugging/pin_cert_example.py new file mode 100644 index 0000000..674fa43 --- /dev/null +++ b/debugging/pin_cert_example.py @@ -0,0 +1,104 @@ +""" +Quick TLS test for FmdClient with certificate pinning or insecure mode. + +Security note: +- Avoid passing passwords on the command line (they can end up in history). This script supports env vars + and secure prompt input so you can omit --password. + +Usage (PowerShell): + # 1) Export server cert to PEM using Python stdlib + # Replace host as needed + python - << 'PY' +import ssl +host = "fmd.example.com" +pem = ssl.get_server_certificate((host, 443)) +open("server-cert.pem", "w").write(pem) +print("Wrote server-cert.pem for", host) +PY + + # 2) Set env vars (recommended) + $env:FMD_ID = "" + $env:FMD_PASSWORD = "" # Consider Read-Host -AsSecureString for interactive use + + # 3) Try connecting with pinned cert (preferred for self-signed) + python debugging/pin_cert_example.py --base-url https://fmd.example.com --ca server-cert.pem + + # 4) Or try insecure mode (development only) + python debugging/pin_cert_example.py --base-url https://fmd.example.com --insecure +""" + +from __future__ import annotations + +import argparse +import asyncio +import ssl +from pathlib import Path +import os +import getpass + +from fmd_api import FmdClient + + +async def run(base_url: str, fmd_id: str, password: str, ca_path: str | None, insecure: bool) -> int: + ssl_arg: object | None + if insecure: + ssl_arg = False + elif ca_path: + ctx = ssl.create_default_context() + ctx.verify_mode = ssl.CERT_REQUIRED + ctx.check_hostname = True + ctx.load_verify_locations(cafile=ca_path) + ssl_arg = ctx + else: + ssl_arg = None # system default validation + + try: + async with await FmdClient.create( + base_url, + fmd_id, + password, + ssl=ssl_arg, + ) as client: + # Minimal call that exercises the API with auth + # This will call /api/v1/locationDataSize + locs = await client.get_locations(num_to_get=0) + print("TLS OK; auth OK; available locations:", len(locs)) + return 0 + except Exception as e: + print("Failed:", e) + return 2 + + +def main() -> int: + p = argparse.ArgumentParser(description="FmdClient TLS/pinning test") + p.add_argument("--base-url", required=True, help="Base URL, must be https://...") + p.add_argument("--id", help="FMD user ID (or set FMD_ID env var)") + p.add_argument("--password", help="FMD password (or set FMD_PASSWORD env var, or omit to be prompted)") + p.add_argument("--ca", dest="ca_path", help="Path to PEM file to trust (pinning or custom CA)") + p.add_argument("--insecure", action="store_true", help="Disable certificate validation (development only)") + args = p.parse_args() + + if args.base_url.lower().startswith("http://"): + p.error("--base-url must be HTTPS (http:// is not allowed)") + + if args.ca_path and not Path(args.ca_path).exists(): + p.error(f"--ca file not found: {args.ca_path}") + + if args.insecure and args.ca_path: + p.error("Use either --ca or --insecure, not both.") + + # Resolve credentials from args, env, or prompt + fmd_id = args.id or os.environ.get("FMD_ID") + if not fmd_id: + fmd_id = input("FMD ID: ") + + password = args.password or os.environ.get("FMD_PASSWORD") + if not password: + # Prompt securely without echo + password = getpass.getpass("Password: ") + + return asyncio.run(run(args.base_url, fmd_id, password, args.ca_path, args.insecure)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debugging/quick_check.py b/debugging/quick_check.py deleted file mode 100644 index bf11098..0000000 --- a/debugging/quick_check.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -import asyncio -import sys -sys.path.insert(0, '..') -from fmd_api import FmdApi -import json -import argparse -from datetime import datetime - -async def check(url, device_id, password): - api = await FmdApi.create(url, device_id, password) - locs = await api.get_all_locations(3) - - print("Last 3 locations:") - for i, loc_blob in enumerate(locs): - loc = json.loads(api.decrypt_data_blob(loc_blob)) - dt = datetime.fromtimestamp(loc['date']/1000) - age = (datetime.now() - dt).total_seconds() - print(f"{i+1}. {dt.strftime('%Y-%m-%d %H:%M:%S')} ({age:.0f}s ago) - {loc.get('provider')} - {loc.get('accuracy', 'N/A')} m") - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Quick check of recent locations') - parser.add_argument('--url', required=True, help='FMD server URL') - parser.add_argument('--id', required=True, help='Device ID') - parser.add_argument('--password', required=True, help='Device password') - args = parser.parse_args() - - asyncio.run(check(args.url, args.id, args.password)) diff --git a/debugging/request_location_example.py b/debugging/request_location_example.py deleted file mode 100644 index 55ca136..0000000 --- a/debugging/request_location_example.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -""" -Example script demonstrating how to request a new location update from a device. - -This script shows the complete workflow: -1. Connect to FMD server and authenticate -2. Send a location request command to the device -3. Wait for the device to capture and upload the location -4. Fetch and display the new location - -Usage: - python request_location_example.py --url https://fmd.example.com --id device-id --password your-password - - Optional arguments: - --provider all|gps|cell|last Location provider to use (default: all) - --wait SECONDS Seconds to wait for location (default: 30) -""" - -import asyncio -import argparse -import json -import sys -import logging -from datetime import datetime -from pathlib import Path - -# Add parent directory to path so we can import fmd_api -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from fmd_api import FmdApi, FmdApiException - -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -log = logging.getLogger(__name__) - - -async def request_and_fetch_location(url: str, device_id: str, password: str, provider: str = "all", wait_seconds: int = 30): - """Request a new location and fetch it after a delay.""" - - try: - # Step 1: Authenticate - log.info(f"Connecting to FMD server: {url}") - api = await FmdApi.create(url, device_id, password) - log.info("✓ Successfully authenticated") - - # Step 2: Get the current most recent location (for comparison) - log.info("Fetching current location for comparison...") - old_locations = await api.get_all_locations(1) - old_location = None - if old_locations and old_locations[0]: - old_location = json.loads(api.decrypt_data_blob(old_locations[0])) - old_time = datetime.fromtimestamp(old_location['date'] / 1000) - log.info(f"Current location captured at: {old_time.strftime('%Y-%m-%d %H:%M:%S')}") - else: - log.info("No previous location found") - - # Step 3: Request a new location - log.info(f"Requesting new location with provider: {provider}") - await api.request_location(provider) - log.info("✓ Location request sent to device") - - # Step 4: Wait for device to respond - log.info(f"Waiting {wait_seconds} seconds for device to capture and upload location...") - await asyncio.sleep(wait_seconds) - - # Step 5: Fetch the new location - log.info("Fetching latest location...") - new_locations = await api.get_all_locations(1) - - if not new_locations or not new_locations[0]: - log.warning("No location found after waiting. Device may be offline or need more time.") - log.info("Tip: Try waiting longer or check if the device has internet connectivity.") - return - - new_location = json.loads(api.decrypt_data_blob(new_locations[0])) - new_time = datetime.fromtimestamp(new_location['date'] / 1000) - - # Step 6: Check if we got a new location - if old_location and new_location['date'] == old_location['date']: - log.warning("Location has not been updated yet. Same timestamp as before.") - log.info("Tip: Device may need more time, or may not have received the command.") - else: - log.info("✓ New location received!") - - # Step 7: Display the location details - print("\n" + "=" * 70) - print("LOCATION DETAILS") - print("=" * 70) - print(f"Timestamp: {new_time.strftime('%Y-%m-%d %H:%M:%S')}") - print(f"Provider: {new_location.get('provider', 'unknown')}") - print(f"Latitude: {new_location['lat']}") - print(f"Longitude: {new_location['lon']}") - print(f"Battery: {new_location.get('bat', 'unknown')}%") - - # Optional fields (GPS-dependent) - if 'accuracy' in new_location: - print(f"Accuracy: {new_location['accuracy']:.1f} meters") - if 'altitude' in new_location: - print(f"Altitude: {new_location['altitude']:.1f} meters") - if 'speed' in new_location: - print(f"Speed: {new_location['speed']:.2f} m/s ({new_location['speed'] * 3.6:.1f} km/h)") - if 'heading' in new_location: - print(f"Heading: {new_location['heading']:.1f}°") - - # Google Maps link - print(f"\nMap link: https://www.google.com/maps?q={new_location['lat']},{new_location['lon']}") - print("=" * 70 + "\n") - - # Provide guidance based on results - if old_location and new_location['date'] == old_location['date']: - print("💡 TIPS FOR TROUBLESHOOTING:") - print(" • Check that the FMD Android app is running on the device") - print(" • Verify the device has internet connectivity") - print(" • GPS locations can take 30-60 seconds to acquire (especially indoors)") - print(" • Try increasing --wait time to 60+ seconds for GPS") - print(" • Use --provider cell for faster (but less accurate) results") - else: - time_diff = (new_location['date'] - old_location['date']) / 1000 if old_location else 0 - if time_diff > 0: - print(f"✓ Location was updated {time_diff:.0f} seconds after the previous one") - - except FmdApiException as e: - log.error(f"FMD API error: {e}") - sys.exit(1) - except Exception as e: - log.error(f"Unexpected error: {e}", exc_info=True) - sys.exit(1) - - -def main(): - parser = argparse.ArgumentParser(description='Request a new location update from an FMD device') - parser.add_argument('--url', required=True, help='FMD server URL (e.g., https://fmd.example.com)') - parser.add_argument('--id', required=True, help='Device ID') - parser.add_argument('--password', required=True, help='Device password') - parser.add_argument('--provider', default='all', choices=['all', 'gps', 'cell', 'last'], - help='Location provider to use (default: all)') - parser.add_argument('--wait', type=int, default=30, - help='Seconds to wait for location update (default: 30)') - - args = parser.parse_args() - - # Run the async function - asyncio.run(request_and_fetch_location(args.url, args.id, args.password, args.provider, args.wait)) - - -if __name__ == '__main__': - main() diff --git a/debugging/show_raw_locations.py b/debugging/show_raw_locations.py deleted file mode 100644 index 12d691a..0000000 --- a/debugging/show_raw_locations.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -"""Quick script to dump raw location JSON to see all available fields""" -import sys -sys.path.insert(0, '..') -import asyncio -import json -import argparse -from fmd_api import FmdApi - -async def main(url, device_id, password): - api = await FmdApi.create(url, device_id, password) - - print("Fetching 20 most recent locations...") - locs = await api.get_all_locations(20) - - print(f"\nFound {len(locs)} locations. Showing first 5:\n") - - for i, loc_blob in enumerate(locs[:5]): - try: - decrypted = api.decrypt_data_blob(loc_blob) - loc_json = json.loads(decrypted) - print(f"Location {i+1}:") - print(json.dumps(loc_json, indent=2)) - print() - except Exception as e: - print(f"Location {i+1}: Error - {e}\n") - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Show raw location JSON') - parser.add_argument('--url', required=True, help='FMD server URL') - parser.add_argument('--id', required=True, help='Device ID') - parser.add_argument('--password', required=True, help='Device password') - args = parser.parse_args() - - asyncio.run(main(args.url, args.id, args.password)) diff --git a/debugging/test_command.py b/debugging/test_command.py deleted file mode 100644 index bef8188..0000000 --- a/debugging/test_command.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -""" -Send commands to an FMD device. - -Available commands: - - ring Make device ring at maximum volume - - lock Lock the device - - locate Request location (all providers) - - locate gps Request GPS location only - - locate cell Request cellular network location - - locate last Get last known location (no new request) - - camera front Take picture with front camera - - camera back Take picture with rear camera - -Usage: - python test_command.py ring - python test_command.py "locate gps" - python test_command.py "camera front" - - # With custom credentials: - python test_command.py ring --url https://fmd.example.com --id device-id --password secret -""" - -import asyncio -import argparse -import sys -sys.path.insert(0, '..') -from fmd_api import FmdApi, FmdApiException - -async def send_command(url: str, device_id: str, password: str, command: str): - """Send a command to the FMD device.""" - try: - print(f"Authenticating with {url}...") - api = await FmdApi.create(url, device_id, password) - print("✓ Authenticated successfully") - - print(f"\nSending command: '{command}'") - result = await api.send_command(command) - - if result: - print("✓ Command sent successfully!") - - # Provide helpful context for each command type - if command == 'ring': - print("\n🔔 Your device should start ringing now!") - print(" The ring will continue until you dismiss it on the device.") - elif command == 'lock': - print("\n🔒 Your device should now be locked.") - elif 'locate' in command: - print("\n📍 Location request sent to device.") - print(" The device will capture and upload location in 10-60 seconds.") - print(" Use request_location_example.py for full workflow with wait/fetch.") - elif 'camera' in command: - print("\n📷 Camera command sent to device.") - print(" The device will take a picture and upload it.") - print(" Check the FMD web interface or use get_pictures() to retrieve it.") - - return True - else: - print("✗ Command sending failed") - return False - - except FmdApiException as e: - print(f"✗ FMD API Error: {e}") - return False - except Exception as e: - print(f"✗ Unexpected error: {e}") - import traceback - traceback.print_exc() - return False - - -def main(): - parser = argparse.ArgumentParser( - description='Send commands to an FMD device', - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Available commands: - ring Make device ring at maximum volume - lock Lock the device - locate Request location (all providers) - locate gps Request GPS location only - locate cell Request cellular network location - locate last Get last known location (no new request) - camera front Take picture with front camera - camera back Take picture with rear camera - -Examples: - python test_command.py ring - python test_command.py "locate gps" - python test_command.py lock --url https://fmd.example.com --id alice --password secret - """ - ) - - parser.add_argument('command', help='Command to send to the device') - parser.add_argument('--url', required=True, help='FMD server URL') - parser.add_argument('--id', required=True, help='Device ID') - parser.add_argument('--password', required=True, help='Device password') - - args = parser.parse_args() - - # Run the async function - success = asyncio.run(send_command(args.url, args.id, args.password, args.command)) - - # Exit with appropriate code - sys.exit(0 if success else 1) - - -if __name__ == '__main__': - main() diff --git a/debugging/test_command_signing.py b/debugging/test_command_signing.py deleted file mode 100644 index ffe45dd..0000000 --- a/debugging/test_command_signing.py +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env python3 -""" -Diagnostic script to test command signing and compare with web client. - -This script will: -1. Show the exact payload being sent -2. Compare signature format with what the web client sends -3. Test if the server accepts the command -4. Check command logs (if available) -""" - -import asyncio -import argparse -import json -import sys -import logging -import time -import base64 -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from fmd_api import FmdApi, FmdApiException -from cryptography.hazmat.primitives.asymmetric import padding -from cryptography.hazmat.primitives import hashes - -logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') -log = logging.getLogger(__name__) - - -async def test_command_signing(url: str, device_id: str, password: str): - """Test command signing in detail.""" - - print("=" * 70) - print("COMMAND SIGNING DIAGNOSTIC") - print("=" * 70) - - # Authenticate - print("\n[1] Authenticating...") - api = await FmdApi.create(url, device_id, password) - print("✓ Authenticated successfully") - - # Get private key info - print(f"\n[2] Private Key Info:") - print(f" Type: {type(api.private_key).__name__}") - print(f" Key Size: {api.private_key.key_size} bits ({api.private_key.key_size // 8} bytes)") - - # Test command - command = "locate gps" - print(f"\n[3] Command to send: '{command}'") - - # Generate timestamp - unix_time_ms = int(time.time() * 1000) - print(f" Unix Time (ms): {unix_time_ms}") - - # Sign the command - command_bytes = command.encode('utf-8') - print(f" Command bytes: {command_bytes.hex()}") - print(f" Command length: {len(command_bytes)} bytes") - - signature = api.private_key.sign( - command_bytes, - padding.PSS( - mgf=padding.MGF1(hashes.SHA256()), - salt_length=32 - ), - hashes.SHA256() - ) - print(f"\n[4] Signature Generated:") - print(f" Raw signature length: {len(signature)} bytes") - print(f" First 32 bytes (hex): {signature[:32].hex()}") - print(f" Last 32 bytes (hex): {signature[-32:].hex()}") - - # Base64 encode - signature_b64 = base64.b64encode(signature).decode('utf-8').rstrip('=') - print(f" Base64 (no padding): {signature_b64[:50]}...{signature_b64[-50:]}") - print(f" Base64 length: {len(signature_b64)} chars") - - # Show full payload - payload = { - "IDT": api.access_token, - "Data": command, - "UnixTime": unix_time_ms, - "CmdSig": signature_b64 - } - - print(f"\n[5] Full Payload:") - print(f" IDT: {api.access_token[:20]}...{api.access_token[-20:]}") - print(f" Data: {command}") - print(f" UnixTime: {unix_time_ms}") - print(f" CmdSig: {signature_b64[:50]}...{signature_b64[-20:]}") - - # Try to send the command - print(f"\n[6] Sending command to server...") - try: - result = await api._make_api_request( - "POST", - "/api/v1/command", - payload, - expect_json=False - ) - print(f"✓ Server Response:") - print(f" Status: SUCCESS") - print(f" Response: {repr(result)}") - print(f" Response type: {type(result)}") - print(f" Response length: {len(result) if result else 0}") - except Exception as e: - print(f"✗ Command FAILED:") - print(f" Error: {e}") - import traceback - traceback.print_exc() - return - - # Wait a moment and check for new location - print(f"\n[7] Waiting 10 seconds to check location count...") - await asyncio.sleep(10) - - size_str = await api._make_api_request("PUT", "/api/v1/locationDataSize", - {"IDT": api.access_token, "Data": "unused"}) - size = int(size_str) - print(f" Current location count: {size}") - - # Try to get the most recent location - print(f"\n[8] Fetching most recent location...") - locations = await api.get_all_locations(1) - if locations and locations[0]: - decrypted = api.decrypt_data_blob(locations[0]) - location = json.loads(decrypted) - from datetime import datetime - loc_time = datetime.fromtimestamp(location['date'] / 1000) - now = datetime.now() - age_seconds = (now - loc_time).total_seconds() - print(f" Location timestamp: {loc_time.strftime('%Y-%m-%d %H:%M:%S')}") - print(f" Age: {age_seconds:.0f} seconds old") - print(f" Provider: {location.get('provider', 'unknown')}") - - if age_seconds < 15: - print(f" ✓ Location is RECENT - command may have worked!") - else: - print(f" ✗ Location is OLD - command may not have reached device") - - # Try to check command logs (if endpoint exists) - print(f"\n[9] Attempting to check command logs...") - try: - logs = await api._make_api_request("PUT", "/api/v1/commandLogs", - {"IDT": api.access_token, "Data": ""}) - print(f" Command logs available!") - print(f" Logs: {logs}") - except Exception as e: - print(f" Command logs not available or encrypted: {e}") - - print("\n" + "=" * 70) - print("DIAGNOSTIC COMPLETE") - print("=" * 70) - - -def main(): - parser = argparse.ArgumentParser(description='Test command signing in detail') - parser.add_argument('--url', required=True, help='FMD server URL') - parser.add_argument('--id', required=True, help='Device ID') - parser.add_argument('--password', required=True, help='Device password') - - args = parser.parse_args() - - asyncio.run(test_command_signing(args.url, args.id, args.password)) - - -if __name__ == '__main__': - main() diff --git a/debugging/test_export.py b/debugging/test_export.py deleted file mode 100644 index fb855ac..0000000 --- a/debugging/test_export.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Quick test of exportData endpoint.""" -import asyncio -import sys -import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -from fmd_api import FmdApi - -async def main(): - if len(sys.argv) < 4: - print("Usage: python test_export.py URL ID PASSWORD") - return - - api = await FmdApi.create(sys.argv[1], sys.argv[2], sys.argv[3]) - print("Testing exportData endpoint...") - try: - # This might return a large file - result = await api._make_api_request('POST', '/api/v1/exportData', - {'IDT': api.access_token, 'Data': 'unused'}) - print(f"Got response, length: {len(result) if result else 0}") - if result and len(result) > 0: - print("Export data endpoint WORKS - data is available via this endpoint!") - print(f"First 200 chars: {result[:200]}") - else: - print("Export data endpoint also returns empty") - except Exception as e: - print(f"Error: {e}") - -asyncio.run(main()) diff --git a/debugging/test_ring.py b/debugging/test_ring.py deleted file mode 100644 index 88aa557..0000000 --- a/debugging/test_ring.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -"""Quick test script for sending ring command to FMD device.""" -import asyncio -import sys -import argparse -sys.path.insert(0, '..') -from fmd_api import FmdApi - -async def send_ring(url, device_id, password): - print("Authenticating...") - api = await FmdApi.create(url, device_id, password) - print("✓ Authenticated") - - print("\nSending 'ring' command to device...") - result = await api.send_command('ring') - - if result: - print("✓ Ring command sent successfully!") - print("\n🔔 Your device should start ringing now!") - print(" (The ring will continue until you dismiss it on the device)") - else: - print("✗ Failed to send ring command") - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Send ring command to FMD device') - parser.add_argument('--url', required=True, help='FMD server URL') - parser.add_argument('--id', required=True, help='Device ID') - parser.add_argument('--password', required=True, help='Device password') - args = parser.parse_args() - - asyncio.run(send_ring(args.url, args.id, args.password)) diff --git a/debugging/test_single_location.py b/debugging/test_single_location.py deleted file mode 100644 index ec065d4..0000000 --- a/debugging/test_single_location.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Test script to debug a single location request and see the raw HTTP response. -""" -import argparse -import asyncio -import sys -import os -import logging -import aiohttp - -# Add parent directory to path to import fmd_api -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -from fmd_api import FmdApi - -# Enable debug logging -logging.basicConfig(level=logging.DEBUG, format='%(name)s - %(levelname)s - %(message)s') - -async def test_raw_request(base_url, token, index): - """Make a raw request to see exactly what the server returns.""" - url = f"{base_url}/api/v1/location" - payload = {"IDT": token, "Data": str(index)} - - print(f"\n=== RAW HTTP REQUEST ===") - print(f"URL: {url}") - print(f"Method: PUT") - print(f"Payload: {payload}") - - async with aiohttp.ClientSession() as session: - async with session.put(url, json=payload) as resp: - print(f"\n=== RAW HTTP RESPONSE ===") - print(f"Status: {resp.status}") - print(f"Headers: {dict(resp.headers)}") - print(f"Content-Type: {resp.content_type}") - print(f"Content-Length: {resp.content_length}") - - # Try reading as bytes - raw_bytes = await resp.read() - print(f"\nRaw bytes length: {len(raw_bytes)}") - print(f"Raw bytes: {raw_bytes}") - - # Try reading as text (need to re-fetch) - - async with aiohttp.ClientSession() as session: - async with session.put(url, json=payload) as resp: - text = await resp.text() - print(f"\nText length: {len(text)}") - print(f"Text repr: {repr(text)}") - - return text - -async def main(): - parser = argparse.ArgumentParser(description="Test single location fetch") - parser.add_argument('--url', required=True, help='Base URL of the FMD server') - parser.add_argument('--id', required=True, help='FMD ID (username)') - parser.add_argument('--password', required=True, help='Password') - parser.add_argument('--index', type=int, default=0, help='Location index to fetch (default: 0)') - parser.add_argument('--test-range', action='store_true', help='Test multiple indices to find valid data') - args = parser.parse_args() - - print("\n=== Authenticating ===") - api = await FmdApi.create(args.url, args.id, args.password) - - print(f"\n=== Getting location count ===") - size_str = await api._make_api_request("PUT", "/api/v1/locationDataSize", {"IDT": api.access_token, "Data": "unused"}) - size = int(size_str) - print(f"Server reports {size} locations available") - - # Test raw request first to see actual server response - print(f"\n=== Testing raw HTTP request ===") - raw_blob = await test_raw_request(args.url.rstrip('/'), api.access_token, args.index) - print(f"Raw response type: {type(raw_blob)}, length: {len(raw_blob) if raw_blob else 0}") - - # Now test using the actual API method - print(f"\n=== Testing via FmdApi._make_api_request ===") - blob = await api._make_api_request("PUT", "/api/v1/location", - {"IDT": api.access_token, "Data": str(args.index)}, - expect_json=True) - - print(f"\n=== Results ===") - print(f"Blob type: {type(blob)}") - print(f"Blob length: {len(blob) if blob else 0}") - print(f"Blob repr: {repr(blob[:100] if blob else blob)}") - - if blob and len(blob) > 0: - print(f"\n=== Attempting to decrypt ===") - try: - decrypted = api.decrypt_data_blob(blob) - print(f"Decrypted successfully!") - print(f"Decrypted data: {decrypted}") - except Exception as e: - print(f"Decryption failed: {e}") - else: - print("\nBlob is empty - cannot decrypt") - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/docs/HOME_ASSISTANT_REVIEW.md b/docs/HOME_ASSISTANT_REVIEW.md new file mode 100644 index 0000000..8e9cebc --- /dev/null +++ b/docs/HOME_ASSISTANT_REVIEW.md @@ -0,0 +1,587 @@ +# Home Assistant Integration Review - Potential Concerns + +This document tracks potential concerns that Home Assistant core developers may raise during integration review. Items are prioritized by severity. + +## Status Legend +- 🔴 **CRITICAL** - Must fix before HA submission +- 🟡 **MAJOR** - Should fix for production quality +- 🟢 **MINOR** - Nice to have improvements + +--- + +## Critical Issues (Must Fix) + +### 🔴 1. Unused `requests` Dependency +**Issue:** `pyproject.toml` lists `requests` as a dependency but the code only uses `aiohttp`. + +**Location:** `pyproject.toml` line 25 +```toml +dependencies = [ + "requests", # ❌ NOT USED + "argon2-cffi", + "cryptography", + "aiohttp", +] +``` + +**Fix:** Remove `requests` from dependencies. + +**HA Rationale:** Home Assistant requires minimal dependencies; unused dependencies will be rejected. + +**Status:** ✅ FIXED + +--- + +### 🔴 2. No Timeouts Configured for HTTP Requests +**Issue:** All `aiohttp` requests in `_make_api_request()` have no timeout configured. + +**Location:** `fmd_api/client.py` line ~180 + +**Risk:** Can hang indefinitely, blocking Home Assistant's event loop. + +**Fix:** +```python +async def _make_api_request(self, ..., timeout: int = 30): + timeout_obj = aiohttp.ClientTimeout(total=timeout) + async with self._session.request(method, url, json=payload, timeout=timeout_obj) as resp: + # ... +``` + +**HA Rationale:** All network calls MUST have timeouts. This is a hard requirement for HA integrations. + +**Status:** ✅ FIXED +- Added `timeout` parameter to `FmdClient.__init__()` with default of 30 seconds +- Applied timeout to all HTTP requests in `_make_api_request()`, `get_pictures()`, and `export_data_zip()` +- Timeout can be overridden at client level or per-request +- Added test coverage with `test_timeout_configuration()` +- All 51 unit tests pass + +--- + +### 🔴 3. Development Version in Production +**Issue:** Version is `2.0.0.dev8` - development versions not allowed for HA integrations. + +**Location:** `pyproject.toml` line 3 + +**Fix:** Release as `2.0.0` stable before submitting to Home Assistant. + +**HA Rationale:** Only stable, released versions accepted as integration dependencies. + +**Status:** ✅ FIXED +- Bumped version to stable `2.0.0` in `pyproject.toml` and `fmd_api/_version.py` +- Built sdist and wheel artifacts for release +- All unit tests passing after version bump + +--- + +### 🔴 4. Inconsistent Version Strings +**Issue:** Version format differs between files: +- `pyproject.toml`: `2.0.0.dev8` (PEP 440 compliant) +- `_version.py`: `2.0.0-dev8` (uses hyphen instead of dot) + +**Location:** +- `pyproject.toml` line 3 +- `fmd_api/_version.py` line 1 + +**Fix:** Use consistent PEP 440 format: `2.0.0.dev8` everywhere, or `2.0.0` for stable. + +**HA Rationale:** Version inconsistencies cause packaging and dependency resolution issues. + +**Status:** ✅ FIXED +- Changed `_version.py` from "2.0.0-dev9" to "2.0.0.dev9" (PEP 440 compliant) +- Both files now use consistent dot notation + +--- + +### 🔴 5. No Rate Limiting or Backoff +**Issue:** No protection against hitting API rate limits. No handling for 429 (Too Many Requests) responses. + +**Location:** `fmd_api/client.py` `_make_api_request()` method + +**Fix:** Implement exponential backoff for rate limit responses: +```python +if resp.status == 429: + retry_after = int(resp.headers.get('Retry-After', 60)) + await asyncio.sleep(retry_after) + # retry logic +``` + +**HA Rationale:** Production integrations must handle rate limits gracefully to avoid service disruption. + +**Status:** ✅ FIXED +- Implemented 429 handling with Retry-After header support and exponential backoff with optional jitter +- Retries for transient 5xx (500/502/503/504) and connection errors +- Avoids unsafe retries for POST /api/v1/command, except on 401 re-auth or 429 with explicit Retry-After +- Configurable via `max_retries`, `backoff_base`, `backoff_max`, `jitter` +- Added unit tests: `test_rate_limit_retry_with_retry_after`, `test_server_error_retry_then_success` +- All unit tests passing + +--- + +### 🔴 6. Missing `py.typed` Marker File +**Issue:** No `py.typed` file means type checkers won't recognize the package's type hints. + +**Location:** Missing from `fmd_api/` directory + +**Fix:** Create empty file `fmd_api/py.typed` + +**HA Rationale:** Type hints are required for HA integrations. The `py.typed` marker enables type checking for library users. + +**Status:** ✅ FIXED +- Created empty `fmd_api/py.typed` marker file +- Type checkers will now recognize the package's type hints per PEP 561 + +--- + +### 🔴 7. No Async Context Manager Support +**Issue:** `FmdClient` requires manual `close()` call. If forgotten, aiohttp sessions leak. + +**Location:** `fmd_api/client.py` class `FmdClient` + +**Fix:** Implement `__aenter__` and `__aexit__`: +```python +class FmdClient: + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + await self.close() +``` + +**Usage:** +```python +async with await FmdClient.create(...) as client: + # auto-closes on exit +``` + +**HA Rationale:** Context managers are the Python standard for resource management. HA prefers libraries that follow this pattern. + +**Status:** ✅ FIXED +- Implemented `__aenter__` and `__aexit__` on `FmdClient` +- Usage supported: + - `async with FmdClient(base_url) as client:` + - `async with await FmdClient.create(base_url, fmd_id, password) as client:` +- On exit, aiohttp session is closed automatically via `close()` +- Added unit tests verifying auto-close behavior + +--- + +## Major Issues (Should Fix) + +### 🟡 8. Python 3.7 Listed But Requires 3.8+ +**Issue:** Classifiers include Python 3.7 but `requires-python = ">=3.8"` + +**Location:** `pyproject.toml` lines 8-15 + +**Fix:** Remove `"Programming Language :: Python :: 3.7"` from classifiers. + +**HA Rationale:** Misleading classifiers can cause installation issues. + +**Status:** ✅ FIXED +- Removed Python 3.7 classifier; `requires-python` is `>=3.8` + +--- + +### 🟡 9. Sensitive Data May Appear in Logs +**Issue:** Debug logging may expose passwords, access tokens, or private keys. + +**Location:** Multiple places in `fmd_api/client.py` + +**Example Issues:** +- Line ~88: Logs may include auth details +- Line ~203: Logs full JSON responses which may contain tokens + +**Fix:** +- Sanitize all log output +- Mask tokens: `log.debug(f"Token: {token[:8]}...")` +- Add guards: `if log.isEnabledFor(logging.DEBUG):` + +**HA Rationale:** Security and privacy requirement for production systems. + +**Status:** ✅ FIXED +- Removed logging of full JSON responses (line ~278); now logs only dict keys +- Removed logging of response body text (line ~285); now logs only length +- Added `_mask_token()` helper for safe token logging (shows first 8 chars) +- Added comment to prevent signature logging in `send_command()` +- Auth flow logs only workflow steps, never actual credentials +- All 55 unit tests passing after sanitization + +--- + +### 🟡 10. No SSL Verification Control +**Issue:** No way to configure SSL certificate verification. Users with self-signed certificates cannot disable verification. + +**Location:** `fmd_api/client.py` `_ensure_session()` method + +**Fix:** Add SSL parameter/connector configuration to constructor: +```python +def __init__(..., ssl: Optional[ssl.SSLContext|bool] = None, ...): + self._ssl = ssl # None=default verify, False=disable, SSLContext=custom + +async def _ensure_session(self): + connector = aiohttp.TCPConnector(ssl=self._ssl, ...) + self._session = aiohttp.ClientSession(connector=connector) +``` + +**HA Rationale:** Enterprise users and development environments need SSL configuration flexibility. + +**Status:** ✅ FIXED +- New constructor options: `ssl` (None | False | SSLContext) +- Verified by unit test `test_connector_configuration_applied` +- Works with self-signed certs when `ssl=False`, or custom trust via SSLContext +- HTTPS is explicitly enforced: `http://` base URLs are rejected by the client + +--- + +### 🟡 11. No Retry Logic for Transient Failures +**Issue:** Single network failure causes operation to fail immediately. No retry for temporary 5xx errors. + +**Location:** `fmd_api/client.py` `_make_api_request()` method + +**Fix:** Implement retry with exponential backoff for 500, 502, 503, 504 errors. + +**HA Rationale:** Improves reliability in production environments with occasional network issues. + +**Status:** ✅ FIXED +- Covered by issue 5 implementation; includes exponential backoff for transient errors + +--- + +### 🟡 12. Type Hints Use `Any` Instead of Specific Types +**Issue:** Return types are too vague, reducing type safety benefits. + +**Examples:** +- `get_pictures() -> List[Any]` - what's in the list? +- `_make_api_request() -> Any` - what type is returned? + +**Location:** Throughout `fmd_api/client.py` + +**Fix:** Define proper types using TypedDict or dataclasses: +```python +from typing import TypedDict + +class PictureDict(TypedDict): + id: int + date: int + # other fields + +async def get_pictures(self, num_to_get: int = -1) -> List[PictureDict]: +``` + +**HA Rationale:** Strong typing helps catch bugs and improves IDE support. + +**Status:** ❌ TODO + +--- + +### 🟡 13. No Connection Pooling Configuration +**Issue:** `ClientSession` created with default connection limits. May not be optimal for all use cases. + +**Location:** `fmd_api/client.py` `_ensure_session()` method + +**Fix:** Allow configuration: +```python +def __init__(self, ..., max_connections: int = 10, max_connections_per_host: int = 5): + self.max_connections = max_connections + self.max_connections_per_host = max_connections_per_host + +async def _ensure_session(self): + connector = aiohttp.TCPConnector( + limit=self.max_connections, + limit_per_host=self.max_connections_per_host + ) + self._session = aiohttp.ClientSession(connector=connector) +``` + +**HA Rationale:** Performance tuning capability for production deployments. + +**Status:** ✅ FIXED +- New constructor options: `conn_limit`, `conn_limit_per_host`, `keepalive_timeout` +- Applied via `aiohttp.TCPConnector` in `_ensure_session()` +- Verified by unit test `test_connector_configuration_applied` + +--- + +### 🟡 14. CPU-Intensive Decryption Blocks Event Loop +**Issue:** `decrypt_data_blob()` is synchronous and performs CPU-intensive RSA/AES operations. + +**Location:** `fmd_api/client.py` `decrypt_data_blob()` method + +**Risk:** Can block Home Assistant's event loop for 100ms+ with large blobs. + +**Fix:** Run in executor for async compatibility: +```python +async def decrypt_data_blob_async(self, data_b64: str) -> bytes: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self.decrypt_data_blob, data_b64) +``` + +**HA Rationale:** Event loop blocking causes UI freezes and integration performance issues. + +**Status:** ❌ TODO + +--- + +## Minor Issues (Nice to Have) + +### 🟢 15. Missing CI/CD Status Badges +**Issue:** README has no build status, test coverage, or version badges. + +**Location:** `README.md` + +**Fix:** Add badges for: +- GitHub Actions build status +- Test coverage (codecov) +- PyPI version +- Python versions supported + +**HA Rationale:** Demonstrates project health and maintenance status. + +**Status:** ❌ TODO + +--- + +### 🟢 16. No CHANGELOG.md +**Issue:** Users can't see what changed between versions. + +**Location:** Missing from repository root + +**Fix:** Add `CHANGELOG.md` following [Keep a Changelog](https://keepachangelog.com/) format. + +**HA Rationale:** Good practice for library maintenance and user communication. + +**Status:** ❌ TODO + +--- + +### 🟢 17. Exception Handling Not Documented +**Issue:** Documentation doesn't clearly state which exceptions can be raised and when. + +**Location:** Docstrings in `fmd_api/client.py` and user documentation + +**Fix:** Document exception hierarchy and when each is raised: +```python +async def get_locations(...) -> List[str]: + """ + ... + + Raises: + AuthenticationError: If authentication fails + FmdApiException: If server returns error + asyncio.TimeoutError: If request times out + aiohttp.ClientError: For network errors + """ +``` + +**HA Rationale:** Users need to know how to handle errors properly. + +**Status:** ❌ TODO + +--- + +### 🟢 18. No Test Coverage Reporting +**Issue:** Test coverage percentage unknown. No coverage reports in CI. + +**Location:** Test configuration + +**Fix:** +- Add `pytest-cov` to dev dependencies +- Configure coverage in `pyproject.toml` +- Add coverage reporting to CI workflow + +**HA Rationale:** Demonstrates code quality and test thoroughness. + +**Status:** ❌ TODO + +--- + +### 🟢 19. Models Not Exported from Package Root +**Issue:** `Location` and `PhotoResult` classes not in `__all__` exports. + +**Location:** `fmd_api/__init__.py` + +**Fix:** Add to exports if users need them: +```python +__all__ = [ + "FmdClient", + "Device", + "Location", + "PhotoResult", + ... +] +``` + +**HA Rationale:** Makes API more discoverable and IDE-friendly. + +**Status:** ❌ TODO + +--- + +### 🟢 20. No Metrics/Observability Hooks +**Issue:** No way to track API call success rates, latencies, or errors. + +**Location:** Architecture design + +**Fix:** Add optional callback hooks: +```python +def __init__(self, ..., on_request=None, on_error=None): + self.on_request = on_request # callback(method, endpoint, duration) + self.on_error = on_error # callback(error, context) +``` + +**HA Rationale:** Helpful for production monitoring and debugging. + +**Status:** ❌ TODO + +--- + +## Security Concerns + +### 🟡 21. Password Stored in Memory for Re-authentication +**Issue:** Password kept as plaintext string in `self._password` for automatic re-authentication. + +**Location:** `fmd_api/client.py` line ~52 + +**Note:** Limited mitigation possible in Python due to string immutability. + +**Recommendation:** Document this behavior in security documentation. + +**HA Rationale:** Users should be aware of security implications. + +**Status:** ❌ TODO (Documentation) + +--- + +### 🟢 22. No Certificate Pinning Option +**Issue:** Can't pin server certificate for high-security deployments. + +**Location:** SSL/TLS configuration + +**Fix:** Add optional SSL context parameter: +```python +def __init__(self, ..., ssl_context: Optional[ssl.SSLContext] = None): + self.ssl_context = ssl_context +``` + +**HA Rationale:** Nice to have for security-conscious deployments. + +**Status:** ❌ TODO + +--- + +## Testing Gaps + +### 🟢 23. No Integration Tests +**Issue:** Only unit tests with mocks. No tests against real FMD server. + +**Location:** `tests/` directory + +**Fix:** Add integration tests (can be optional/manual with real credentials). + +**HA Rationale:** Increases confidence in production reliability. + +**Status:** ❌ TODO + +--- + +## Priority Summary + +**Before HA Submission (Critical):** +1. Remove unused `requests` dependency +2. Add HTTP request timeouts +3. Release stable 2.0.0 version +4. Fix version string inconsistency +5. Add `py.typed` file +6. Implement async context manager +7. Add rate limit handling — DONE + +**For Production Quality (Major):** +- Fix Python 3.7 classifier — DONE +- Sanitize logs (security) — DONE +- Add SSL verification control — DONE +- Improve type hints +- Add retry logic — DONE +- Configure connection pooling — DONE +- Make decryption async + +**For Best Practices (Minor):** +- Add CI badges — PARTIAL (Added Tests + Codecov badges; PyPI/version badges pending) +- Create CHANGELOG.md +- Document exceptions +- Add test coverage reporting +- Export all public models + +--- + +## CI/CD Quality Gates + +### 🔴 24. Automated Test Runner +**Issue:** CI workflow added to run unit tests on PRs/pushes. + +**Impact:** Prevents merging broken code; validates before publish. + +**Fix:** GitHub Actions workflow runs pytest on Python 3.8–3.12 across ubuntu/windows. + +**Status:** ✅ FIXED + +--- + +### 🟡 25. Linting in CI +**Issue:** Linting now enforced with flake8. + +**Fix:** Added flake8 step to CI workflow; configured `.flake8` for ignores/excludes. + +**Status:** ✅ FIXED + +--- + +### 🟡 26. Type Checking in CI +**Issue:** Type checking now enforced with mypy. + +**Fix:** Added mypy step to CI workflow. + +**Status:** ✅ FIXED + +--- + +### 🟢 27. Coverage Reporting +**Issue:** Coverage measurement now implemented; badge still pending. + +**Fix:** Added pytest-cov with XML output and Codecov upload in CI (matrixed across OS/Python). Use `--cov-branch` for branch coverage. + +**Status:** ✅ FIXED (Badge pending) + +--- + +### 🟢 28. No Dependency Security Scanning +**Issue:** No vulnerability checks on dependencies. + +**Fix:** Enable GitHub Dependabot or add safety checks to CI. + +**Status:** ❌ TODO (Minor) + +--- + +## Review Checklist + +Before submitting to Home Assistant: + +- [ ] All critical issues resolved +- [ ] Major security concerns addressed +- [ ] Type hints complete and accurate +- [ ] Documentation comprehensive +- [ ] Test coverage > 80% +- [ ] CHANGELOG.md up to date +- [ ] Stable version released to PyPI +- [ ] Code passes `flake8` and `mypy` +- [ ] CI runs tests on all supported Python versions +- [ ] CI enforces linting and type checking + +--- + +## References + +- [Home Assistant Integration Requirements](https://developers.home-assistant.io/docs/creating_integration_manifest) +- [Home Assistant Code Quality](https://developers.home-assistant.io/docs/development_validation) +- [PEP 440 - Version Identification](https://peps.python.org/pep-0440/) +- [PEP 561 - Distributing and Packaging Type Information](https://peps.python.org/pep-0561/) diff --git a/docs/MIGRATE_FROM_V1.md b/docs/MIGRATE_FROM_V1.md new file mode 100644 index 0000000..f880a17 --- /dev/null +++ b/docs/MIGRATE_FROM_V1.md @@ -0,0 +1,382 @@ +# Migrating from fmd_api v1 to v2 + +# Migrating from fmd_api v1 (module style) to v2 (FmdClient + Device) + +## Overview + +This short guide shows common v1 usages (from fmd_api.py) and how to perform the + +**fmd_api v2.0** introduces a major architectural change from the monolithic `FmdApi` class to a device-oriented design with `FmdClient` and `Device` classes. This guide helps you migrate your code from v1 (0.1.x) to v2 (2.0.x).equivalent actions using the new FmdClient and Device classes. + + + +### Key ChangesAuthenticate + +v1: + +1. **Device-Oriented Architecture**: V2 introduces a `Device` class that wraps common device operations```python + +2. **Method Renaming**: `toggle_*` methods renamed to `set_*` for clarityapi = await FmdApi.create("https://fmd.example.com", "alice", "secret") +3. **Import Changes**: `FmdApi` → `FmdClient`, new `Device` class available +4. **Method Deprecations**: Some v1 methods like `get_all_locations()` renamed to `get_locations()` +5. **Constants Removed**: `FmdCommands` constants removed (use string commands directly) + +--- + +## Quick Start: Before and After + +### Authentication + +**V1:** +```python +from fmd_api import FmdApi + +api = await FmdApi.create("https://fmd.example.com", "alice", "secret") +``` + +**V2:** +```python +from fmd_api import FmdClient + +client = await FmdClient.create("https://fmd.example.com", "alice", "secret") +``` + +--- + +## Complete Migration Table + +### Authentication & Setup + +| V1 | V2 | Notes | +|----|----|-------| +| `from fmd_api import FmdApi` | `from fmd_api import FmdClient, Device` | Import names changed | +| `api = await FmdApi.create(url, id, pw)` | `client = await FmdClient.create(url, id, pw)` | Class renamed | +| `await api.close()` | `await client.close()` | Same method | + +### Location Methods + +| V1 | V2 | Notes | +|----|----|-------| +| `await api.get_all_locations(10)` | `await client.get_locations(10)` | Method renamed | +| `api.decrypt_data_blob(blob)` | `client.decrypt_data_blob(blob)` | Same method | +| `await api.request_location('gps')` | `await client.request_location('gps')` | Same method | + +### Device Commands + +| V1 | V2 (FmdClient) | V2 (Device) | Notes | +|----|----------------|-------------|-------| +| `await api.send_command('ring')` | `await client.send_command('ring')` | `await device.play_sound()` | Device method preferred | +| `await api.send_command('lock')` | `await client.send_command('lock')` | `await device.lock()` | Device method preferred | +| `await api.send_command('delete')` | `await client.send_command('delete')` | `await device.wipe(confirm=True)` | **REQUIRES confirm flag** | + +### Camera Commands + +| V1 | V2 (FmdClient) | V2 (Device) | Notes | +|----|----------------|-------------|-------| +| `await api.take_picture('back')` | `await client.take_picture('back')` | `await device.take_rear_photo()` | Device method preferred | +| `await api.take_picture('front')` | `await client.take_picture('front')` | `await device.take_front_photo()` | Device method preferred | + +### Bluetooth & Audio Settings + +| V1 | V2 | Notes | +|----|----|-------| +| `await api.toggle_bluetooth(True)` | `await client.set_bluetooth(True)` | Method renamed | +| `await api.toggle_bluetooth(False)` | `await client.set_bluetooth(False)` | Method renamed | +| `await api.toggle_do_not_disturb(True)` | `await client.set_do_not_disturb(True)` | Method renamed | +| `await api.toggle_do_not_disturb(False)` | `await client.set_do_not_disturb(False)` | Method renamed | +| `await api.set_ringer_mode('normal')` | `await client.set_ringer_mode('normal')` | Same method | + +### Pictures + +| V1 | V2 (FmdClient) | V2 (Device) | Notes | +|----|----------------|-------------|-------| +| `await api.get_pictures(10)` | `await client.get_pictures(10)` | `await device.fetch_pictures(10)` | Both available | +| N/A | N/A | `await device.download_photo(blob)` | New helper method | + +### Device Stats + +| V1 | V2 | Notes | +|----|----|-------| +| `await api.get_device_stats()` | `await client.get_device_stats()` | Same method | + +### Export Data + +| V1 | V2 | Notes | +|----|----|-------| +| `await api.export_data_zip('output.zip')` | `await client.export_data_zip('output.zip')` | Same method | + +### Constants (Removed) + +| V1 | V2 | Notes | +|----|----|-------| +| `FmdCommands.RING` | `'ring'` | Use string directly | +| `FmdCommands.LOCATE_GPS` | `'locate gps'` | Use string directly | +| `FmdCommands.BLUETOOTH_ON` | `'bluetooth on'` | Use string directly | +| `FmdCommands.NODISTURB_ON` | `'nodisturb on'` | Use string directly | +| `FmdCommands.RINGERMODE_VIBRATE` | `'ringermode vibrate'` | Use string directly | + +--- + +## New Device-Oriented API + +V2 introduces the `Device` class for cleaner device-specific operations: + +### Creating a Device Instance + +```python +from fmd_api import FmdClient, Device + +# Authenticate with client +client = await FmdClient.create("https://fmd.example.com", "alice", "secret") + +# Create device instance +device = Device(client, "alice") + +# Now use device methods +await device.refresh() +location = await device.get_location() +print(f"Lat: {location.lat}, Lon: {location.lon}") +``` + +### Device Methods + +```python +# Get current location (with caching) +location = await device.get_location() +location = await device.get_location(force=True) # Force refresh + +# Get location history as async iterator +async for location in device.get_history(limit=10): + print(f"Location at {location.date}: {location.lat}, {location.lon}") + +# Device commands +await device.play_sound() # Ring device +await device.take_rear_photo() # Rear camera +await device.take_front_photo() # Front camera +await device.lock(message="Lost device") # Lock with message +await device.wipe(confirm=True) # Factory reset (DESTRUCTIVE) + +# Pictures +pictures = await device.fetch_pictures(10) +photo_result = await device.download_photo(pictures[0]) +``` + +--- + +## Complete Migration Examples + +### Example 1: Get Latest Location + +**V1:** +```python +import asyncio +import json +from fmd_api import FmdApi + +async def main(): + api = await FmdApi.create("https://fmd.example.com", "alice", "secret") + + # Request new location + await api.request_location('gps') + await asyncio.sleep(30) + + # Get locations + blobs = await api.get_all_locations(1) + location_json = api.decrypt_data_blob(blobs[0]) + location = json.loads(location_json) + + print(f"Lat: {location['lat']}, Lon: {location['lon']}") + await api.close() + +asyncio.run(main()) +``` + +**V2 (Client-based):** +```python +import asyncio +import json +from fmd_api import FmdClient + +async def main(): + client = await FmdClient.create("https://fmd.example.com", "alice", "secret") + + # Request new location + await client.request_location('gps') + await asyncio.sleep(30) + + # Get locations + blobs = await client.get_locations(1) + location_json = client.decrypt_data_blob(blobs[0]) + location = json.loads(location_json) + + print(f"Lat: {location['lat']}, Lon: {location['lon']}") + await client.close() + +asyncio.run(main()) +``` + +**V2 (Device-oriented, RECOMMENDED):** +```python +import asyncio +from fmd_api import FmdClient, Device + +async def main(): + client = await FmdClient.create("https://fmd.example.com", "alice", "secret") + device = Device(client, "alice") + + # Request and get location (simplified) + await client.request_location('gps') + await asyncio.sleep(30) + + location = await device.get_location(force=True) + print(f"Lat: {location.lat}, Lon: {location.lon}") + + await client.close() + +asyncio.run(main()) +``` + +### Example 2: Send Commands + +**V1:** +```python +from fmd_api import FmdApi, FmdCommands + +async def control_device(): + api = await FmdApi.create("https://fmd.example.com", "alice", "secret") + + # Using constants + await api.send_command(FmdCommands.RING) + await api.send_command(FmdCommands.BLUETOOTH_ON) + + # Using convenience methods + await api.toggle_bluetooth(True) + await api.toggle_do_not_disturb(True) + await api.set_ringer_mode('vibrate') + + await api.close() +``` + +**V2 (Client-based):** +```python +from fmd_api import FmdClient + +async def control_device(): + client = await FmdClient.create("https://fmd.example.com", "alice", "secret") + + # Use strings directly (constants removed) + await client.send_command('ring') + await client.send_command('bluetooth on') + + # Using convenience methods (renamed from toggle_* to set_*) + await client.set_bluetooth(True) + await client.set_do_not_disturb(True) + await client.set_ringer_mode('vibrate') + + await client.close() +``` + +**V2 (Device-oriented, RECOMMENDED):** +```python +from fmd_api import FmdClient, Device + +async def control_device(): + client = await FmdClient.create("https://fmd.example.com", "alice", "secret") + device = Device(client, "alice") + + # Use device methods for cleaner API + await device.play_sound() + + # Settings still use client + await client.set_bluetooth(True) + await client.set_do_not_disturb(True) + await client.set_ringer_mode('vibrate') + + await client.close() +``` + +### Example 3: Get Location History + +**V1:** +```python +import json +from fmd_api import FmdApi + +async def get_history(): + api = await FmdApi.create("https://fmd.example.com", "alice", "secret") + + blobs = await api.get_all_locations(10) + for blob in blobs: + location_json = api.decrypt_data_blob(blob) + location = json.loads(location_json) + print(f"Date: {location['date']}, Lat: {location['lat']}, Lon: {location['lon']}") + + await api.close() +``` + +**V2 (Device-oriented, RECOMMENDED):** +```python +from fmd_api import FmdClient, Device + +async def get_history(): + client = await FmdClient.create("https://fmd.example.com", "alice", "secret") + device = Device(client, "alice") + + # Async iterator with automatic decryption + async for location in device.get_history(limit=10): + print(f"Date: {location.date}, Lat: {location.lat}, Lon: {location.lon}") + + await client.close() +``` + +--- + +## Breaking Changes Summary + +### Required Changes + +1. **Import statements**: Replace `FmdApi` with `FmdClient` +2. **Method renames**: + - `get_all_locations()` → `get_locations()` + - `toggle_bluetooth()` → `set_bluetooth()` + - `toggle_do_not_disturb()` → `set_do_not_disturb()` +3. **Constants removed**: Replace `FmdCommands.*` with string literals +4. **Wipe command**: Now requires `confirm=True` when using Device class + +### Optional But Recommended + +1. **Use Device class** for device-specific operations +2. **Use async iteration** for location history: `async for location in device.get_history()` +3. **Use Location objects** instead of raw JSON dictionaries + +--- + +## Compatibility Notes + +- **Python 3.8+** required (same as v1) +- **Dependencies**: Same dependencies (aiohttp, argon2-cffi, cryptography) +- **Server compatibility**: V2 works with the same FMD server as V1 +- **Data format**: Location and picture data formats unchanged + +--- + +## Migration Checklist + +- [ ] Update imports: `FmdApi` → `FmdClient` +- [ ] Rename `get_all_locations()` → `get_locations()` +- [ ] Rename `toggle_bluetooth()` → `set_bluetooth()` +- [ ] Rename `toggle_do_not_disturb()` → `set_do_not_disturb()` +- [ ] Replace `FmdCommands` constants with strings +- [ ] Consider using `Device` class for cleaner code +- [ ] Update `wipe()` calls to include `confirm=True` +- [ ] Test all functionality with your FMD server + +--- + +## Getting Help + +- **Issues**: https://github.com/devinslick/fmd_api/issues +- **Documentation**: https://github.com/devinslick/fmd_api#readme +- **Version**: Check `fmd_api.__version__` + +For additional examples, see the `tests/functional/` directory in the repository. diff --git a/docs/PROPOSAL.md b/docs/PROPOSAL.md new file mode 100644 index 0000000..d97904b --- /dev/null +++ b/docs/PROPOSAL.md @@ -0,0 +1,289 @@ +# Proposal: fmd_api v2 — Device-centric async interface + +Status: Draft +Author: devinslick (proposal by Copilot Space) +Date: 2025-11-01 + +## Goals + +- Replace the current functional-style API with a small object model that exposes a Device class representing a single tracked device. +- Keep all existing behavior and business logic of the current implementation, but convert operations to awaitable async methods to satisfy Home Assistant integration requirements. +- Provide an idiomatic, easy-to-test, and extensible interface: + - Device objects that encapsulate identifiers, state, and operations (refresh, play_sound, locate, take photos, etc). + - A lightweight Client that handles authentication, session management, rate limiting, and discovery of devices. +- Keep the migration path simple: do NOT provide an in-code legacy compatibility layer. Instead include a short migration README that helps developers move from the old API to the new device-centric API. +- Include unit-test and integration-test guidance and examples. + +## Overview of the new design + +Top-level components: +- FmdClient: an async client that manages session, authentication tokens, request throttling, and device discovery. +- Device: represents a single device and exposes async methods to interact with it (async refresh(), async play_sound(), async get_location(), async take_front_photo(), async take_rear_photo(), async lock_device(), async wipe_device(), etc). +- Exceptions: typed exceptions for common error cases (AuthenticationError, DeviceNotFoundError, FmdApiError, RateLimitError). +- Utilities: small helpers for caching, TTL-based per-device caches, retry/backoff, JSON parsing. + +Rationale: +- Home Assistant requires async-enabled integrations to avoid blocking the event loop. Converting to async lets this library integrate smoothly. +- Representing a device as an object makes the API easier to reason about, test, and extend. +- Centralized client manages authentication and rate-limiting across multiple Device instances. +- Avoiding a built-in legacy shim keeps the code simple and encourages direct migration. + +## Public API + +Example usage: + +```python +from fmd_api import FmdClient + +async def example(): + client = FmdClient(username="me@example.com", password="hunter2") + await client.authenticate() + + devices = await client.get_devices() # list[Device] + device = devices[0] + + # Get latest known location (from cache or backend) + loc = await device.get_location() + + # Force a refresh from backend + await device.refresh(force=True) + + # Trigger play sound + await device.play_sound() + + # Take front and rear photos + front = await device.take_front_photo() + rear = await device.take_rear_photo() + + # Lock device with message + await device.lock_device(message="Lost phone — call me") + + # Wipe device (dangerous) + # await device.wipe_device(confirm=True) + + # Close client when finished + await client.close() +``` + +Core classes and signatures (proposal): + +- FmdClient + - __init__(self, username: str, password: str, session: Optional[aiohttp.ClientSession] = None, *, base_url: Optional[str] = None, request_timeout: int = 10, rate_limiter: Optional[RateLimiter] = None, cache_ttl: int = 30) + - async authenticate(self) -> None + - async get_devices(self) -> list["Device"] + - async get_device(self, device_id: str) -> "Device" + - async close(self) -> None + - properties: + - auth_token (read-only) + - is_authenticated: bool + - cache_ttl: int + +- Device + - Attributes: + - client: FmdClient (back-reference) + - id: str + - name: str + - model: Optional[str] + - battery: Optional[int] + - is_online: Optional[bool] + - last_seen: Optional[datetime] + - cached_location: Optional[Location] + - raw: dict + - Methods (async): + - async refresh(self, *, force: bool = False) -> None + - Updates device state and location from backend. Honor per-device cache TTL when force=False. + - async get_location(self, *, force: bool = False) -> Optional[Location] + - Returns last known location (calls refresh if expired or force=True) + - async play_sound(self, *, volume: Optional[int] = None) -> None + - async take_front_photo(self) -> Optional[bytes] + - Requests a front-facing photo; returns raw bytes of image if available. + - async take_rear_photo(self) -> Optional[bytes] + - Requests a rear-facing photo; returns raw bytes of image if available. + - async lock_device(self, *, passcode: Optional[str] = None, message: Optional[str] = None) -> None + - async wipe_device(self, *, confirm: bool = False) -> None + - async set_label(self, label: str) -> None + - to_dict(self) -> dict + - __repr__/__str__ helper for debugging + +- Location (dataclass) + - lat: float + - lon: float + - accuracy_m: Optional[float] + - timestamp: datetime + - raw: dict + +- PhotoResult (dataclass) + - bytes: bytes + - mime_type: str + - timestamp: datetime + - raw: dict + +- Exceptions + - FmdApiError(Exception) + - AuthenticationError(FmdApiError) + - DeviceNotFoundError(FmdApiError) + - RateLimitError(FmdApiError) + - OperationError(FmdApiError) + +## Behavior details & compatibility with current logic + +- All request payloads, parsing, and business rules will reuse the logic currently implemented in the repository (parsing of responses, mapping fields to device properties, handling of play sound semantics, etc.). No functional changes to endpoints or command behavior are intended. +- Where current code uses synchronous HTTP (requests), the new client will use asyncio/aiohttp to make non-blocking calls. Helpers will be introduced to convert existing request/response handling functions to async easily. +- Device.refresh() mirrors current "get devices" and "refresh device" flows: fetch the device status endpoint, parse location, battery, and update fields. +- Photo functions: take_front_photo() and take_rear_photo() call the corresponding FMD endpoints (if supported). They should return either a PhotoResult object (preferred) or None if not supported by the device/account. Implementations should include sensible timeouts and handle partial results gracefully. +- Caching: to avoid hitting rate limits and reduce backend load, a per-device TTL cache will be implemented (configurable; default 30 seconds). get_location() uses cached data unless force=True or stale. +- Rate limiting: a shared RateLimiter object will enforce a maximum requests-per-second or requests-per-minute per client instance. Simple token-bucket or asyncio.Semaphore + sleep-backoff will be sufficient. +- Retries: transient HTTP errors will be retried with an exponential backoff (configurable; default 3 retries). +- Error handling: HTTP 401 triggers AuthenticationError; 404 for device endpoints raises DeviceNotFoundError; 429 triggers RateLimitError. + +## Async design considerations + +- Use aiohttp.ClientSession for requests. The session can be provided by a caller (for reuse) or created by the client. +- All public methods are async and return without blocking the event loop. +- Methods that call multiple network endpoints (for example, refresh that fetches multiple resources) should gather coroutines where parallelism is safe, using asyncio.gather. +- Allow integration with Home Assistant through the standard pattern: construct client during integration setup, call client.authenticate() in async_setup_entry(), create entities that hold Device references, and use DataUpdateCoordinator(s). + +## Migration notes (from current repo) + +- We will NOT ship an in-code legacy compatibility adapter. Instead, include a short README (docs/MIGRATE_FROM_V1.md) with: + - A mapping table of old function names to the new FmdClient/Device equivalents. + - Short code snippets showing how to migrate common flows (list devices, refresh, ring, take photos). + - Notes about switching to async and recommended patterns for calling from synchronous scripts (e.g., using asyncio.run or running inside an existing loop). +- Example migration snippet: + +Old v1 (sync): +```python +d = get_device("device-id") +location = d["location"] +ring_device("device-id") +``` + +New v2 (async): +```python +client = FmdClient(username="u", password="p") +await client.authenticate() +device = await client.get_device("device-id") +location = await device.get_location() +await device.play_sound() +``` + +## Testing + +- Unit tests: + - Mock aiohttp responses using aioresponses or pytest-aiohttp. + - Test Device.refresh, Device.get_location caching behavior, play_sound, take_front_photo/take_rear_photo, lock_device, wipe_device, error mappings, and retry logic. +- Integration tests: + - Optionally include an integration test suite that can run against a staging backend or recorded responses (VCR-like fixture). +- Linting: + - Enforce mypy typing for public API and add tests that ensure Device methods are awaitable. + +## Implementation plan (phased) + +1. Agree on method names and shapes in this proposal. +2. Implement FmdClient and Device classes with core methods: authenticate, get_devices, get_device, Device.refresh, Device.get_location, Device.play_sound, Device.take_front_photo, Device.take_rear_photo. +3. Port parsing logic from current code into async request handlers. +4. Implement caching, rate limiting, and retries. +5. Add docs/MIGRATE_FROM_V1.md migration guide and examples. +6. Add Home Assistant integration notes and an example integration using DataUpdateCoordinator. +7. Add unit/integration tests and CI (GitHub Actions). +8. Release v2.0.0 with upgrade notes. + +Estimated effort: +- Core async client + Device, basic tests: 1–2 days +- Full feature parity (all endpoints + play/lock/wipe/photos): 2–3 days +- Tests + CI + HA docs: 1–2 days + +## File layout proposal + +- fmd_api/ + - __init__.py # exports FmdClient, Device, exceptions + - client.py # FmdClient implementation + - device.py # Device model & methods + - types.py # dataclasses: Location, PhotoResult, DeviceInfo + - exceptions.py # typed exceptions + - rate_limiter.py # simple rate limiter utilities + - cache.py # TTL cache helpers + - helpers.py # utility functions, parsers + - tests/ + - test_client.py + - test_device.py + - fixtures/ +- docs/ + - ha_integration.md + - MIGRATE_FROM_V1.md # short migration guide from existing repo +- examples/ + - async_example.py + +## Home Assistant integration notes + +- Use FmdClient in the integration's async_setup_entry() method. +- Create a DataUpdateCoordinator per entry or a single coordinator that manages all devices: + - Coordinator calls client.get_devices() periodically and updates entities. + - Entities keep a reference to their Device object and read properties directly. +- Expose device actions (play_sound, take_front_photo, take_rear_photo, lock, wipe) through Home Assistant services that call the corresponding async Device methods. + +Example HA pattern: +- On setup: + - client = FmdClient(...) + - await client.authenticate() + - devices = await client.get_devices() + - coordinator = DataUpdateCoordinator(..., update_method=client.get_devices) + - create entities that hold Device references + +## Example Device class (sketch) + +```python +class Device: + def __init__(self, client: FmdClient, raw: dict): + self.client = client + self.id = raw["id"] + self.name = raw.get("name") + self.raw = raw + self.cached_location: Optional[Location] = None + self._last_refresh = datetime.min + + async def refresh(self, *, force: bool = False) -> None: + if not force and (utcnow() - self._last_refresh).total_seconds() < self.client.cache_ttl: + return + data = await self.client._request("GET", f"/devices/{self.id}") + self._update_from_raw(data) + self._last_refresh = utcnow() + + async def get_location(self, *, force: bool = False) -> Optional[Location]: + await self.refresh(force=force) + return self.cached_location + + async def play_sound(self, *, volume: Optional[int] = None) -> None: + await self.client._request("POST", f"/devices/{self.id}/play_sound", json={"volume": volume} if volume else None) + + async def take_front_photo(self) -> Optional[PhotoResult]: + resp = await self.client._request("POST", f"/devices/{self.id}/take_photo", json={"camera": "front"}) + return parse_photo_result(resp) + + async def take_rear_photo(self) -> Optional[PhotoResult]: + resp = await self.client._request("POST", f"/devices/{self.id}/take_photo", json={"camera": "rear"}) + return parse_photo_result(resp) +``` + +## Security considerations + +- Store tokens securely; do not log secrets. Client will redact token values from debug logs. +- Encourage the use of per-integration API keys if the backend supports them. +- Provide options to scope rate limits and avoid accidental account lockouts via aggressive command usage. + +## API docs & examples + +- Add README sections showing: + - Basic usage (authenticate, list devices, one-liners) + - Example usage inside Home Assistant + - Migration guide from v1 to v2 (docs/MIGRATE_FROM_V1.md) + - How to run tests + +## Closing + +This proposal updates the earlier draft to: +- Use the more concise FmdClient name (no Async prefix). +- Include explicit methods for taking front and rear photos. +- Drop an in-code legacy compatibility layer and instead provide a small migration README. + +If you approve, I will create a branch and a PR that implements the core FmdClient and Device class with the initial methods (authenticate, get_devices, get_device, Device.refresh, Device.get_location, Device.play_sound, Device.take_front_photo, Device.take_rear_photo), plus tests and example usage. \ No newline at end of file diff --git a/docs/PROPOSED_BRANCH_AND_STRUCTURE.md b/docs/PROPOSED_BRANCH_AND_STRUCTURE.md new file mode 100644 index 0000000..fb06b53 --- /dev/null +++ b/docs/PROPOSED_BRANCH_AND_STRUCTURE.md @@ -0,0 +1,45 @@ +```markdown +Branch: feature/v2-device-client + +This document proposes the initial branch and repository layout for fmd_api v2 +(FmdClient + Device). The branch name above is the suggested feature branch +to create in the repository. + +Goals for the branch: +- Add package layout and initial skeleton modules that port the current + fmd_api.py behavior into a FmdClient class and a Device class while + preserving the decryption, auth, and command semantics. +- Provide minimal, well-typed skeletons and README + migration doc to guide + further work and make it easy to add tests incrementally. + +Top-level layout (files to add in feature branch) +- fmd_api/ + - __init__.py + - client.py + - device.py + - types.py + - exceptions.py + - helpers.py + - _version.py + - tests/ + - __init__.py + - test_client.py + - test_device.py +- docs/ + - MIGRATE_FROM_V1.md + - ha_integration.md +- examples/ + - async_example.py +- PROPOSAL.md # updated proposal (keeps current proposal but with final details) +- pyproject.toml (skeleton) +- tox.ini / .github/workflows/ci.yml (placeholders) + +Next steps after branch creation: +1. Implement FmdClient.create() by porting fmd_api.FmdApi.create and its helper methods. +2. Implement decrypt_data_blob unchanged and expose as FmdClient.decrypt_data_blob. +3. Add Device wrappers (take_picture, request_location, get_locations -> get_history/get_location). +4. Add tests that assert parity for authentication and decrypt_data_blob (using recorded values or mocks). +5. Iterate on rate-limiter/cache and add streaming helpers for export_data_zip. + +If you'd like, I can now generate the initial skeleton files for this branch (client.py, device.py, types.py, exceptions.py, helpers.py, docs/MIGRATE_FROM_V1.md, examples/async_example.py, PROPOSAL.md). Which files would you like me to create first? +``` \ No newline at end of file diff --git a/docs/release/v2.0.0_merge_request.md b/docs/release/v2.0.0_merge_request.md new file mode 100644 index 0000000..1cc85f9 --- /dev/null +++ b/docs/release/v2.0.0_merge_request.md @@ -0,0 +1,83 @@ +# Release v2.0.0: Async client, TLS enforcement, retries/backoff, and CI upgrades + +## Summary +This major release delivers a production-ready async client with robust TLS handling, resilient request logic (timeouts, retries, rate limits), and a fully wired CI pipeline (lint, type-check, tests, coverage). It replaces the legacy synchronous v1 client and prepares the project for Home Assistant and broader integration. + +## Highlights +- **Async client** + - New `FmdClient` with an async factory (`await FmdClient.create(...)`) and async context manager (`async with ...`). + - Clean session lifecycle management and connection pooling controls. +- **TLS/SSL** + - HTTPS-only `base_url` enforcement (rejects plain HTTP). + - Configurable validation: `ssl=False` (dev only) or pass a custom `ssl.SSLContext`. + - Certificate pinning example included in docs. +- **Reliability** + - Timeouts applied across all HTTP requests. + - Retries with exponential backoff and optional jitter for 5xx and connection errors. + - 429 rate-limit handling with Retry-After support. + - Safe behavior for command POSTs (no unsafe retries). +- **Features** + - Client-side export to ZIP (locations + pictures). + - Device helper with convenience actions (e.g., request location, play sound, take picture). +- **Safety and ergonomics** + - Sanitized logging (no sensitive payloads); token masking helper. + - Typed package (`py.typed`) and mypy-clean. +- **CI/CD** + - GitHub Actions: lint (flake8), type-check (mypy), unit tests matrix (Ubuntu/Windows; Py 3.8–3.12). + - Coverage with branch analysis and Codecov upload + badges. + - Publish workflows for TestPyPI (non-main) and PyPI (main or Release). + +## Breaking changes +- API surface moved to async: + - Before (v1, sync): `client = FmdApi(...); client.authenticate(...); client.get_all_locations()` + - Now (v2, async): + - `client = await FmdClient.create(base_url, fmd_id, password, ...)` + - `await client.get_locations(...)`, `await client.get_pictures(...)`, `await client.send_command(...)` + - Prefer: `async with FmdClient.create(...) as client: ...` +- Transport requirements: + - `base_url` must be HTTPS; plain HTTP raises `ValueError`. +- Python versions: + - Targets Python 3.8+ (3.7 removed from classifiers). + +## Migration guide +- Replace `FmdApi` usage with the async `FmdClient`: + - Use `await FmdClient.create(...)` and `async with` for safe resource management. + - Update all calls to await the async methods. +- TLS and self-signed setups: + - For dev-only scenarios: pass `ssl=False`. + - For proper self-signed use: pass a custom `ssl.SSLContext`. + - For high-security setups: consider the certificate pinning example in README. +- Connection tuning: + - Optional: `conn_limit`, `conn_limit_per_host`, `keepalive_timeout` on the TCPConnector via client init. + +## Error handling and semantics +- 401 triggers one automatic re-authenticate then retries the request. +- 429 honors Retry-After, otherwise uses exponential backoff with jitter. +- Transient 5xx/connection errors are retried (except unsafe command POST replays). +- Exceptions are normalized to `FmdApiException` where appropriate; messages mask sensitive data. + +## Documentation and examples +- README: TLS/self-signed guidance, warnings, and certificate pinning example. +- Debugging: `pin_cert_example.py` demonstrates secure pinning and avoids CLI secrets. + +## CI/CD and release automation +- Tests: unit suite expanded; functional tests run when credentials are available. +- Coverage: ~83% overall; XML + branch coverage uploaded to Codecov (badges included). +- Workflows: + - `test.yml`: runs on push/PR for all branches (lint, mypy, unit tests matrix, coverage, optional functional tests). + - `publish.yml`: builds on push/releases; publishes to TestPyPI for non-main pushes, PyPI on main or release. + +## Checklist +- [x] All unit tests pass +- [x] Flake8 clean +- [x] Mypy clean +- [x] Coverage collected and uploaded +- [x] README/docs updated (TLS, pinning, badges) +- [x] Packaging: sdist and wheel built; publish workflows configured + +## Notes +- Use `ssl=False` sparingly and never in production. +- Consider adding Dependabot and security scanning in a follow-up. +- A `CHANGELOG.md` entry for 2.0.0 is recommended if not already included. + +> BREAKING CHANGE: v2 switches to an async client, enforces HTTPS, and drops Python 3.7; synchronous v1 usage must be migrated as noted above. diff --git a/fmd_api.py b/fmd_api.py deleted file mode 100644 index 359f76b..0000000 --- a/fmd_api.py +++ /dev/null @@ -1,752 +0,0 @@ -""" -Core API library for interacting with an FMD server. - -This module provides a client that communicates with FMD (Find My Device) servers -using the FMD API protocol, encryption scheme, and command format. - -FMD Project Attribution: - - FMD (Find My Device): https://fmd-foss.org - - Created by Nulide (http://nulide.de) - - Maintained by Thore (https://thore.io) and the FMD-FOSS team - - FMD Server: https://gitlab.com/fmd-foss/fmd-server (AGPL-3.0) - - FMD Android: https://gitlab.com/fmd-foss/fmd-android (GPL-3.0) - -This Client Implementation: - - MIT License - Copyright (c) 2025 Devin Slick - - Independent client implementation for FMD API - - Follows FMD's RSA-3072 + AES-GCM encryption protocol - - Compatible with FMD server v012.0 API - -This module provides a class that handles authentication, key management, -and data decryption for FMD clients. - -Example Usage: - import asyncio - import json - from fmd_api import FmdApi - - async def main(): - # Authenticate and create API client - api = await FmdApi.create( - 'https://fmd.example.com', - 'your-device-id', - 'your-password' - ) - - # Get the 10 most recent locations - location_blobs = await api.get_all_locations(num_to_get=10) - - # Decrypt and parse each location - for blob in location_blobs: - decrypted_bytes = api.decrypt_data_blob(blob) - location = json.loads(decrypted_bytes) - - # Always-present fields: - timestamp = location['time'] # Human-readable: "Sat Oct 18 14:08:20 CDT 2025" - date_ms = location['date'] # Unix timestamp in milliseconds - provider = location['provider'] # "gps", "network", "fused", or "BeaconDB" - battery = location['bat'] # Battery percentage (0-100) - latitude = location['lat'] # Latitude in degrees - longitude = location['lon'] # Longitude in degrees - - # Optional fields (use .get() with default): - accuracy = location.get('accuracy') # GPS accuracy in meters (float) - altitude = location.get('altitude') # Altitude in meters (float) - speed = location.get('speed') # Speed in meters/second (float) - heading = location.get('heading') # Direction in degrees 0-360 (float) - - # Example: Convert speed to km/h and print if moving - if speed is not None and speed > 0.5: # Moving faster than 0.5 m/s - speed_kmh = speed * 3.6 - direction = heading if heading else "unknown" - print(f"{timestamp}: Moving at {speed_kmh:.1f} km/h, heading {direction}°") - else: - print(f"{timestamp}: Stationary at ({latitude}, {longitude})") - - asyncio.run(main()) - -Location Data Field Reference: - Always Present: - - time (str): Human-readable timestamp - - date (int): Unix timestamp in milliseconds - - provider (str): Location provider name - - bat (int): Battery percentage - - lat (float): Latitude - - lon (float): Longitude - - Optional (GPS/Movement-Dependent): - - accuracy (float): GPS accuracy radius in meters - - altitude (float): Altitude above sea level in meters - - speed (float): Speed in meters per second (only when moving) - - heading (float): Direction in degrees 0-360 (only when moving with direction) -""" -import base64 -import json -import logging -import time -import aiohttp -from argon2.low_level import hash_secret_raw, Type -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import padding -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.ciphers.aead import AESGCM - -# --- Constants --- -CONTEXT_STRING_LOGIN = "context:loginAuthentication" -CONTEXT_STRING_ASYM_KEY_WRAP = "context:asymmetricKeyWrap" -ARGON2_SALT_LENGTH = 16 -AES_GCM_IV_SIZE_BYTES = 12 -RSA_KEY_SIZE_BYTES = 384 # 3072 bits / 8 - -log = logging.getLogger(__name__) - -class FmdApiException(Exception): - """Base exception for FMD API errors.""" - pass - -class FmdCommands: - """ - Constants for available FMD device commands. - - These commands are supported by the FMD Android app and can be sent - via the send_command() method. Using these constants helps prevent typos - and improves code discoverability. - - Command Categories: - Location Requests: - LOCATE_ALL, LOCATE_GPS, LOCATE_CELL, LOCATE_LAST - - Device Control: - RING, LOCK, DELETE - - Camera: - CAMERA_FRONT, CAMERA_BACK - - Audio/Notifications: - BLUETOOTH_ON, BLUETOOTH_OFF - NODISTURB_ON, NODISTURB_OFF - RINGERMODE_NORMAL, RINGERMODE_VIBRATE, RINGERMODE_SILENT - - Information: - STATS, GPS (battery/GPS status) - - Example: - from fmd_api import FmdApi, FmdCommands - - api = await FmdApi.create('https://fmd.example.com', 'device-id', 'password') - - # Ring the device - await api.send_command(FmdCommands.RING) - - # Request GPS location - await api.send_command(FmdCommands.LOCATE_GPS) - - # Enable Do Not Disturb - await api.send_command(FmdCommands.NODISTURB_ON) - """ - # Location requests - LOCATE_ALL = "locate" - LOCATE_GPS = "locate gps" - LOCATE_CELL = "locate cell" - LOCATE_LAST = "locate last" - - # Device control - RING = "ring" - LOCK = "lock" - DELETE = "delete" # Wipes device data (destructive!) - - # Camera - CAMERA_FRONT = "camera front" - CAMERA_BACK = "camera back" - - # Bluetooth - BLUETOOTH_ON = "bluetooth on" - BLUETOOTH_OFF = "bluetooth off" - - # Do Not Disturb - NODISTURB_ON = "nodisturb on" - NODISTURB_OFF = "nodisturb off" - - # Ringer Mode - RINGERMODE_NORMAL = "ringermode normal" - RINGERMODE_VIBRATE = "ringermode vibrate" - RINGERMODE_SILENT = "ringermode silent" - - # Information/Status - STATS = "stats" # Network info (IP addresses, WiFi networks) - GPS = "gps" # Battery and GPS status - -def _pad_base64(s): - return s + '=' * (-len(s) % 4) - -class FmdApi: - """A client for the FMD server API.""" - - def __init__(self, base_url, session_duration=3600): - self.base_url = base_url.rstrip('/') - self.access_token = None - self.private_key = None - self.session_duration = session_duration - self._fmd_id = None - self._password = None - - @classmethod - async def create(cls, base_url, fmd_id, password, session_duration=3600): - """Creates and authenticates an FmdApi instance.""" - instance = cls(base_url, session_duration) - instance._fmd_id = fmd_id - instance._password = password - await instance.authenticate(fmd_id, password, session_duration) - return instance - - async def authenticate(self, fmd_id, password, session_duration): - """Performs the full authentication and key retrieval workflow.""" - log.info("[1] Requesting salt...") - salt = await self._get_salt(fmd_id) - log.info("[2] Hashing password with salt...") - password_hash = self._hash_password(password, salt) - log.info("[3] Requesting access token...") - self.fmd_id = fmd_id - self.access_token = await self._get_access_token(fmd_id, password_hash, session_duration) - - log.info("[3a] Retrieving encrypted private key...") - privkey_blob = await self._get_private_key_blob() - log.info("[3b] Decrypting private key...") - privkey_bytes = self._decrypt_private_key_blob(privkey_blob, password) - self.private_key = self._load_private_key_from_bytes(privkey_bytes) - - def _hash_password(self, password: str, salt: str) -> str: - salt_bytes = base64.b64decode(_pad_base64(salt)) - password_bytes = (CONTEXT_STRING_LOGIN + password).encode('utf-8') - hash_bytes = hash_secret_raw( - secret=password_bytes, salt=salt_bytes, time_cost=1, - memory_cost=131072, parallelism=4, hash_len=32, type=Type.ID - ) - hash_b64 = base64.b64encode(hash_bytes).decode('utf-8').rstrip('=') - return f"$argon2id$v=19$m=131072,t=1,p=4${salt}${hash_b64}" - - async def _get_salt(self, fmd_id): - return await self._make_api_request("PUT", "/api/v1/salt", {"IDT": fmd_id, "Data": ""}) - - async def _get_access_token(self, fmd_id, password_hash, session_duration): - payload = { - "IDT": fmd_id, "Data": password_hash, - "SessionDurationSeconds": session_duration - } - return await self._make_api_request("PUT", "/api/v1/requestAccess", payload) - - async def _get_private_key_blob(self): - return await self._make_api_request("PUT", "/api/v1/key", {"IDT": self.access_token, "Data": "unused"}) - - def _decrypt_private_key_blob(self, key_b64: str, password: str) -> bytes: - key_bytes = base64.b64decode(_pad_base64(key_b64)) - salt = key_bytes[:ARGON2_SALT_LENGTH] - iv = key_bytes[ARGON2_SALT_LENGTH:ARGON2_SALT_LENGTH + AES_GCM_IV_SIZE_BYTES] - ciphertext = key_bytes[ARGON2_SALT_LENGTH + AES_GCM_IV_SIZE_BYTES:] - password_bytes = (CONTEXT_STRING_ASYM_KEY_WRAP + password).encode('utf-8') - aes_key = hash_secret_raw( - secret=password_bytes, salt=salt, time_cost=1, memory_cost=131072, - parallelism=4, hash_len=32, type=Type.ID - ) - aesgcm = AESGCM(aes_key) - return aesgcm.decrypt(iv, ciphertext, None) - - def _load_private_key_from_bytes(self, privkey_bytes: bytes): - try: - return serialization.load_pem_private_key(privkey_bytes, password=None) - except ValueError: - return serialization.load_der_private_key(privkey_bytes, password=None) - - def decrypt_data_blob(self, data_b64: str) -> bytes: - """Decrypts a location or picture data blob using the instance's private key. - - Args: - data_b64: Base64-encoded encrypted blob from the server - - Returns: - bytes: Decrypted data (JSON for locations, base64 string for pictures) - - Raises: - FmdApiException: If blob is too small or decryption fails - - Example: - # For locations: - location_blob = await api.get_all_locations(1) - decrypted = api.decrypt_data_blob(location_blob[0]) - location = json.loads(decrypted) - - # Access fields: - lat = location['lat'] - lon = location['lon'] - accuracy = location.get('accuracy') # Optional field - speed = location.get('speed') # Optional, only when moving - heading = location.get('heading') # Optional, only when moving - """ - blob = base64.b64decode(_pad_base64(data_b64)) - - # Check if blob is large enough to contain encrypted data - min_size = RSA_KEY_SIZE_BYTES + AES_GCM_IV_SIZE_BYTES - if len(blob) < min_size: - raise FmdApiException( - f"Blob too small for decryption: {len(blob)} bytes (expected at least {min_size} bytes). " - f"This may indicate empty/invalid data from the server." - ) - - session_key_packet = blob[:RSA_KEY_SIZE_BYTES] - iv = blob[RSA_KEY_SIZE_BYTES:RSA_KEY_SIZE_BYTES + AES_GCM_IV_SIZE_BYTES] - ciphertext = blob[RSA_KEY_SIZE_BYTES + AES_GCM_IV_SIZE_BYTES:] - session_key = self.private_key.decrypt( - session_key_packet, - padding.OAEP( - mgf=padding.MGF1(algorithm=hashes.SHA256()), - algorithm=hashes.SHA256(), label=None - ) - ) - aesgcm = AESGCM(session_key) - return aesgcm.decrypt(iv, ciphertext, None) - - async def _make_api_request(self, method, endpoint, payload, stream=False, expect_json=True, retry_auth=True): - """Helper function for making API requests.""" - url = self.base_url + endpoint - try: - async with aiohttp.ClientSession() as session: - async with session.request(method, url, json=payload) as resp: - # Handle 401 Unauthorized by re-authenticating - if resp.status == 401 and retry_auth and self._fmd_id and self._password: - log.info("Received 401 Unauthorized, re-authenticating...") - await self.authenticate(self._fmd_id, self._password, self.session_duration) - # Retry the request with new token - payload["IDT"] = self.access_token - return await self._make_api_request(method, endpoint, payload, stream, expect_json, retry_auth=False) - - resp.raise_for_status() - - # Log response details for debugging - log.debug(f"{endpoint} response - status: {resp.status}, content-type: {resp.content_type}, content-length: {resp.content_length}") - - if not stream: - if expect_json: - # FMD server sometimes returns wrong content-type (application/octet-stream instead of application/json) - # Use content_type=None to force JSON parsing regardless of Content-Type header - try: - json_data = await resp.json(content_type=None) - log.debug(f"{endpoint} JSON response: {json_data}") - return json_data["Data"] - except (KeyError, ValueError, json.JSONDecodeError) as e: - # If JSON parsing fails, fall back to text - log.debug(f"{endpoint} JSON parsing failed ({e}), trying as text") - text_data = await resp.text() - log.debug(f"{endpoint} returned text length: {len(text_data)}") - if text_data: - log.debug(f"{endpoint} first 200 chars: {text_data[:200]}") - else: - log.warning(f"{endpoint} returned EMPTY response body") - return text_data - else: - text_data = await resp.text() - log.debug(f"{endpoint} text response status: {resp.status}, content-type: {resp.content_type}") - log.debug(f"{endpoint} text response length: {len(text_data)}, content: {text_data[:500]}") - return text_data - else: - return resp - except aiohttp.ClientError as e: - log.error(f"API request failed for {endpoint}: {e}") - raise FmdApiException(f"API request failed for {endpoint}: {e}") from e - except (KeyError, ValueError) as e: - log.error(f"Failed to parse server response for {endpoint}: {e}") - raise FmdApiException(f"Failed to parse server response for {endpoint}: {e}") from e - - async def get_all_locations(self, num_to_get=-1, skip_empty=True, max_attempts=10): - """Fetches all or the N most recent location blobs. - - Args: - num_to_get: Number of locations to get (-1 for all) - skip_empty: If True, skip empty blobs and search backwards for valid data - max_attempts: Maximum number of indices to try when skip_empty is True - """ - log.debug(f"Getting locations, num_to_get={num_to_get}, skip_empty={skip_empty}") - size_str = await self._make_api_request("PUT", "/api/v1/locationDataSize", {"IDT": self.access_token, "Data": "unused"}) - size = int(size_str) - log.debug(f"Server reports {size} locations available") - if size == 0: - log.info("No locations found to download.") - return [] - - locations = [] - if num_to_get == -1: # Download all - log.info(f"Found {size} locations to download.") - indices = range(size) - # Download all, don't skip any - for i in indices: - log.info(f" - Downloading location at index {i}...") - blob = await self._make_api_request("PUT", "/api/v1/location", {"IDT": self.access_token, "Data": str(i)}) - locations.append(blob) - return locations - else: # Download N most recent - num_to_download = min(num_to_get, size) - log.info(f"Found {size} locations. Downloading the {num_to_download} most recent.") - start_index = size - 1 - - if skip_empty: - # When skipping empties, we'll try indices one at a time starting from most recent - indices = range(start_index, max(0, start_index - max_attempts), -1) - log.info(f"Will search for {num_to_download} non-empty location(s) starting from index {start_index}") - else: - end_index = size - num_to_download - log.debug(f"Index calculation: start={start_index}, end={end_index}, range=({start_index}, {end_index - 1}, -1)") - indices = range(start_index, end_index - 1, -1) - log.info(f"Will fetch indices: {list(indices)}") - - for i in indices: - log.info(f" - Downloading location at index {i}...") - blob = await self._make_api_request("PUT", "/api/v1/location", {"IDT": self.access_token, "Data": str(i)}) - log.debug(f"Received blob type: {type(blob)}, length: {len(blob) if blob else 0}") - if blob and blob.strip(): # Check for non-empty, non-whitespace - log.debug(f"First 100 chars: {blob[:100]}") - locations.append(blob) - log.info(f"Found valid location at index {i}") - # If we got enough non-empty locations, stop - if len(locations) >= num_to_get and num_to_get != -1: - break - else: - log.warning(f"Empty blob received for location index {i}, repr: {repr(blob[:50] if blob else blob)}") - - if not locations and num_to_get != -1: - log.warning(f"No valid locations found after checking {min(max_attempts, size)} indices") - - return locations - - async def get_pictures(self, num_to_get=-1): - """Fetches all or the N most recent picture blobs.""" - try: - async with aiohttp.ClientSession() as session: - async with session.put(f"{self.base_url}/api/v1/pictures", json={"IDT": self.access_token, "Data": ""}) as resp: - resp.raise_for_status() - all_pictures = await resp.json() - except aiohttp.ClientError as e: - log.warning(f"Failed to get pictures: {e}. The endpoint may not exist or requires a different method.") - return [] - - if num_to_get == -1: # Download all - log.info(f"Found {len(all_pictures)} pictures to download.") - return all_pictures - else: # Download N most recent - num_to_download = min(num_to_get, len(all_pictures)) - log.info(f"Found {len(all_pictures)} pictures. Selecting the {num_to_download} most recent.") - return all_pictures[-num_to_download:][::-1] - - async def export_data_zip(self, output_file): - """Downloads the pre-packaged export data zip file.""" - try: - async with aiohttp.ClientSession() as session: - async with session.post(f"{self.base_url}/api/v1/exportData", json={"IDT": self.access_token, "Data": "unused"}) as resp: - resp.raise_for_status() - with open(output_file, 'wb') as f: - while True: - chunk = await resp.content.read(8192) - if not chunk: - break - f.write(chunk) - log.info(f"Exported data saved to {output_file}") - except aiohttp.ClientError as e: - log.error(f"Failed to export data: {e}") - raise FmdApiException(f"Failed to export data: {e}") from e - async def send_command(self, command: str) -> bool: - """Sends a command to the device. - - Complete list of available commands (or use FmdCommands constants): - - Location Requests: - - "locate" - Request location using all available providers (GPS, network, fused) - - "locate gps" - GPS-only location (most accurate, requires clear sky view) - - "locate cell" - Cellular network location (fast, less accurate) - - "locate last" - Return last known location without new request - - Device Control: - - "ring" - Make device ring at full volume (ignores silent/DND mode) - - "lock" - Lock the device screen - - "delete" - ⚠️ DESTRUCTIVE: Wipes all device data (factory reset) - - Camera: - - "camera front" - Take photo with front-facing camera - - "camera back" - Take photo with rear-facing camera - - Audio & Notifications: - - "bluetooth on" - Enable Bluetooth (Android 12+ requires permission) - - "bluetooth off" - Disable Bluetooth - - "nodisturb on" - Enable Do Not Disturb mode (requires permission) - - "nodisturb off" - Disable Do Not Disturb mode - - "ringermode normal" - Set ringer to normal (sound + vibrate) - - "ringermode vibrate" - Set ringer to vibrate only - - "ringermode silent" - Set ringer to silent (also enables DND) - - Information/Status: - - "stats" - Get network info (IP addresses, WiFi SSID/BSSID) - - "gps" - Get battery level and GPS status - - Args: - command: The command string to send (see list above or use FmdCommands constants) - - Returns: - bool: True if command was sent successfully to the server - - Raises: - FmdApiException: If command sending fails - - Note: - Commands are sent to the server immediately, but execution on the device - depends on the device being online and the FMD app having necessary permissions. - Some commands (bluetooth, nodisturb, ringermode) require special Android permissions. - - Examples: - # Using string commands - await api.send_command("ring") - await api.send_command("locate gps") - await api.send_command("bluetooth on") - await api.send_command("nodisturb on") - await api.send_command("ringermode vibrate") - - # Using constants (recommended to prevent typos) - from fmd_api import FmdCommands - await api.send_command(FmdCommands.RING) - await api.send_command(FmdCommands.LOCATE_GPS) - await api.send_command(FmdCommands.BLUETOOTH_ON) - await api.send_command(FmdCommands.NODISTURB_ON) - await api.send_command(FmdCommands.RINGERMODE_VIBRATE) - """ - log.info(f"Sending command to device: {command}") - - # Get current Unix time in milliseconds - unix_time_ms = int(time.time() * 1000) - - # Sign the command using RSA-PSS - # IMPORTANT: The web client signs "timestamp:command", not just the command! - # See fmd-server/web/logic.js line 489: sign(key, `${time}:${message}`) - message_to_sign = f"{unix_time_ms}:{command}" - message_bytes = message_to_sign.encode('utf-8') - signature = self.private_key.sign( - message_bytes, - padding.PSS( - mgf=padding.MGF1(hashes.SHA256()), - salt_length=32 - ), - hashes.SHA256() - ) - signature_b64 = base64.b64encode(signature).decode('utf-8').rstrip('=') - - try: - result = await self._make_api_request( - "POST", - "/api/v1/command", - { - "IDT": self.access_token, - "Data": command, - "UnixTime": unix_time_ms, - "CmdSig": signature_b64 - }, - expect_json=False - ) - log.info(f"Command sent successfully: {command}") - return True - except Exception as e: - log.error(f"Failed to send command '{command}': {e}") - raise FmdApiException(f"Failed to send command '{command}': {e}") from e - - async def request_location(self, provider: str = "all") -> bool: - """Convenience method to request a new location update from the device. - - This triggers the FMD Android app to capture a new location and upload it - to the server. The location will be available after a short delay (typically - 10-60 seconds depending on GPS acquisition time). - - Args: - provider: Which location provider to use: - - "all" (default): Use all available providers (GPS, network, fused) - - "gps": GPS only (most accurate, slower, requires clear sky) - - "cell" or "network": Cellular network location (fast, less accurate) - - "last": Don't request new location, just get last known - - Returns: - bool: True if request was sent successfully - - Raises: - FmdApiException: If request fails - - Example: - # Request a new GPS-only location - api = await FmdApi.create('https://fmd.example.com', 'device-id', 'password') - await api.request_location('gps') - - # Wait for device to capture and upload location - await asyncio.sleep(30) - - # Fetch the new location - locations = await api.get_all_locations(1) - location = json.loads(api.decrypt_data_blob(locations[0])) - print(f"New location: {location['lat']}, {location['lon']}") - """ - provider_map = { - "all": "locate", - "gps": "locate gps", - "cell": "locate cell", - "network": "locate cell", - "last": "locate last" - } - - command = provider_map.get(provider.lower(), "locate") - log.info(f"Requesting location update with provider: {provider} (command: {command})") - return await self.send_command(command) - - async def toggle_bluetooth(self, enable: bool) -> bool: - """Enable or disable Bluetooth on the device. - - Args: - enable: True to enable Bluetooth, False to disable - - Returns: - bool: True if command was sent successfully - - Raises: - FmdApiException: If command sending fails - - Note: - On Android 12+, the FMD app requires BLUETOOTH_CONNECT permission. - - Example: - # Enable Bluetooth - await api.toggle_bluetooth(True) - - # Disable Bluetooth - await api.toggle_bluetooth(False) - """ - command = FmdCommands.BLUETOOTH_ON if enable else FmdCommands.BLUETOOTH_OFF - log.info(f"{'Enabling' if enable else 'Disabling'} Bluetooth") - return await self.send_command(command) - - async def toggle_do_not_disturb(self, enable: bool) -> bool: - """Enable or disable Do Not Disturb mode on the device. - - Args: - enable: True to enable DND mode, False to disable - - Returns: - bool: True if command was sent successfully - - Raises: - FmdApiException: If command sending fails - - Note: - Requires Do Not Disturb Access permission on the device. - - Example: - # Enable Do Not Disturb - await api.toggle_do_not_disturb(True) - - # Disable Do Not Disturb - await api.toggle_do_not_disturb(False) - """ - command = FmdCommands.NODISTURB_ON if enable else FmdCommands.NODISTURB_OFF - log.info(f"{'Enabling' if enable else 'Disabling'} Do Not Disturb mode") - return await self.send_command(command) - - async def set_ringer_mode(self, mode: str) -> bool: - """Set the device ringer mode. - - Args: - mode: Ringer mode to set: - - "normal": Sound + vibrate enabled - - "vibrate": Vibrate only, no sound - - "silent": Silent mode (also enables Do Not Disturb) - - Returns: - bool: True if command was sent successfully - - Raises: - FmdApiException: If command sending fails or invalid mode - ValueError: If mode is not one of the valid options - - Note: - - Setting to "silent" also enables Do Not Disturb mode (Android behavior) - - Requires Do Not Disturb Access permission on the device - - Example: - # Set to vibrate only - await api.set_ringer_mode("vibrate") - - # Set to normal (sound + vibrate) - await api.set_ringer_mode("normal") - - # Set to silent (also enables DND) - await api.set_ringer_mode("silent") - """ - mode = mode.lower() - mode_map = { - "normal": FmdCommands.RINGERMODE_NORMAL, - "vibrate": FmdCommands.RINGERMODE_VIBRATE, - "silent": FmdCommands.RINGERMODE_SILENT - } - - if mode not in mode_map: - raise ValueError(f"Invalid ringer mode '{mode}'. Must be 'normal', 'vibrate', or 'silent'") - - command = mode_map[mode] - log.info(f"Setting ringer mode to: {mode}") - return await self.send_command(command) - - async def get_device_stats(self) -> bool: - """Request device network statistics (IP addresses, WiFi info). - - The device will respond with information about: - - IP addresses (IPv4 and IPv6) - - Connected WiFi networks (SSID and BSSID) - - Returns: - bool: True if command was sent successfully - - Raises: - FmdApiException: If command sending fails - - Note: - Requires Location permission on the device (needed to access WiFi info). - The response is sent back to the device via the transport mechanism - (e.g., SMS, push notification) rather than stored on the server. - - Example: - # Request device statistics - await api.get_device_stats() - """ - log.info("Requesting device network statistics") - return await self.send_command(FmdCommands.STATS) - - async def take_picture(self, camera: str = "back") -> bool: - """Request the device to take a picture. - - Args: - camera: Which camera to use - "front" or "back" (default: "back") - - Returns: - bool: True if command was sent successfully - - Raises: - FmdApiException: If command sending fails - ValueError: If camera is not "front" or "back" - - Note: - Pictures are uploaded to the server and can be retrieved using - get_all_pictures() and decrypt_data_blob(). - - Example: - # Take picture with rear camera - await api.take_picture("back") - - # Take picture with front camera (selfie) - await api.take_picture("front") - """ - camera = camera.lower() - if camera not in ["front", "back"]: - raise ValueError(f"Invalid camera '{camera}'. Must be 'front' or 'back'") - - command = FmdCommands.CAMERA_FRONT if camera == "front" else FmdCommands.CAMERA_BACK - log.info(f"Requesting picture from {camera} camera") - return await self.send_command(command) - diff --git a/fmd_api/__init__.py b/fmd_api/__init__.py new file mode 100644 index 0000000..0bc9b83 --- /dev/null +++ b/fmd_api/__init__.py @@ -0,0 +1,15 @@ +# fmd_api package exports +from .client import FmdClient +from .device import Device +from .exceptions import FmdApiException, AuthenticationError, DeviceNotFoundError, OperationError +from ._version import __version__ + +__all__ = [ + "FmdClient", + "Device", + "FmdApiException", + "AuthenticationError", + "DeviceNotFoundError", + "OperationError", + "__version__", +] diff --git a/fmd_api/_version.py b/fmd_api/_version.py new file mode 100644 index 0000000..159d48b --- /dev/null +++ b/fmd_api/_version.py @@ -0,0 +1 @@ +__version__ = "2.0.1" diff --git a/fmd_api/client.py b/fmd_api/client.py new file mode 100644 index 0000000..9829b73 --- /dev/null +++ b/fmd_api/client.py @@ -0,0 +1,718 @@ +""" +FmdClient: port of the original fmd_api.FmdApi into an async client class. + +This module implements: + - authenticate (salt -> argon2 -> requestAccess -> get private key blob -> decrypt) + - decrypt_data_blob (RSA session key + AES-GCM) + - _make_api_request (aiohttp wrapper with re-auth on 401, JSON/text fallback, streaming) + - get_locations (port of get_all_locations) + - get_pictures (port of get_pictures) + - export_data_zip (streamed download) + - send_command (RSA-PSS signing and POST to /api/v1/command) + - convenience wrappers: request_location, set_bluetooth, set_do_not_disturb, + set_ringer_mode, get_device_stats, take_picture +""" + +from __future__ import annotations + +import base64 +import asyncio +import json +import logging +import time +import random +from typing import Optional, List, Any, cast + +import aiohttp +from argon2.low_level import hash_secret_raw, Type +from cryptography.hazmat.primitives import serialization, hashes +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from .helpers import _pad_base64 +from .exceptions import FmdApiException + +# Constants copied from original module to ensure parity +CONTEXT_STRING_LOGIN = "context:loginAuthentication" +CONTEXT_STRING_ASYM_KEY_WRAP = "context:asymmetricKeyWrap" +ARGON2_SALT_LENGTH = 16 +AES_GCM_IV_SIZE_BYTES = 12 +RSA_KEY_SIZE_BYTES = 384 # 3072 bits / 8 + +log = logging.getLogger(__name__) + + +class FmdClient: + def __init__( + self, + base_url: str, + session_duration: int = 3600, + *, + cache_ttl: int = 30, + timeout: float = 30.0, + max_retries: int = 3, + backoff_base: float = 0.5, + backoff_max: float = 10.0, + jitter: bool = True, + ssl: Optional[Any] = None, + conn_limit: Optional[int] = None, + conn_limit_per_host: Optional[int] = None, + keepalive_timeout: Optional[float] = None, + ): + # Enforce HTTPS only (FindMyDevice always uses TLS) + if base_url.lower().startswith("http://"): + raise ValueError("HTTPS is required for FmdClient base_url; plain HTTP is not allowed.") + self.base_url = base_url.rstrip("/") + self.session_duration = session_duration + self.cache_ttl = cache_ttl + self.timeout = timeout # default timeout for all HTTP requests (seconds) + self.max_retries = max(0, int(max_retries)) + self.backoff_base = float(backoff_base) + self.backoff_max = float(backoff_max) + self.jitter = bool(jitter) + + # Connection/session configuration + # ssl can be: None (default validation), False (disable verification), or an SSLContext + self._ssl = ssl + self._conn_limit = conn_limit + self._conn_limit_per_host = conn_limit_per_host + self._keepalive_timeout = keepalive_timeout + + self._fmd_id: Optional[str] = None + self._password: Optional[str] = None + self.access_token: Optional[str] = None + self.private_key: Optional[RSAPrivateKey] = None # cryptography private key object + + self._session: Optional[aiohttp.ClientSession] = None + + # ------------------------- + # Async context manager + # ------------------------- + async def __aenter__(self) -> "FmdClient": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.close() + + @classmethod + async def create( + cls, + base_url: str, + fmd_id: str, + password: str, + session_duration: int = 3600, + *, + cache_ttl: int = 30, + timeout: float = 30.0, + ssl: Optional[Any] = None, + conn_limit: Optional[int] = None, + conn_limit_per_host: Optional[int] = None, + keepalive_timeout: Optional[float] = None, + ): + inst = cls( + base_url, + session_duration, + cache_ttl=cache_ttl, + timeout=timeout, + ssl=ssl, + conn_limit=conn_limit, + conn_limit_per_host=conn_limit_per_host, + keepalive_timeout=keepalive_timeout, + ) + inst._fmd_id = fmd_id + inst._password = password + try: + await inst.authenticate(fmd_id, password, session_duration) + except Exception: + # Ensure we don't leak a ClientSession if auth fails mid-creation + await inst.close() + raise + return inst + + async def _ensure_session(self) -> None: + if self._session is None or self._session.closed: + connector_kwargs = {} + if self._ssl is not None: + connector_kwargs["ssl"] = self._ssl + if self._conn_limit is not None: + connector_kwargs["limit"] = self._conn_limit + if self._conn_limit_per_host is not None: + connector_kwargs["limit_per_host"] = self._conn_limit_per_host + if self._keepalive_timeout is not None: + connector_kwargs["keepalive_timeout"] = self._keepalive_timeout + + connector = aiohttp.TCPConnector(**connector_kwargs) + self._session = aiohttp.ClientSession(connector=connector) + + async def close(self) -> None: + if self._session and not self._session.closed: + await self._session.close() + self._session = None + + # ------------------------- + # Authentication helpers + # ------------------------- + async def authenticate(self, fmd_id: str, password: str, session_duration: int) -> None: + """ + Performs the full authentication and private key retrieval workflow. + Mirrors the behavior in the original fmd_api.FmdApi. + """ + log.info("[1] Requesting salt...") + salt = await self._get_salt(fmd_id) + log.info("[2] Hashing password with salt...") + password_hash = self._hash_password(password, salt) + log.info("[3] Requesting access token...") + self._fmd_id = fmd_id + self._password = password + self.access_token = await self._get_access_token(fmd_id, password_hash, session_duration) + + log.info("[3a] Retrieving encrypted private key...") + privkey_blob = await self._get_private_key_blob() + log.info("[3b] Decrypting private key...") + privkey_bytes = self._decrypt_private_key_blob(privkey_blob, password) + self.private_key = self._load_private_key_from_bytes(privkey_bytes) + + def _hash_password(self, password: str, salt: str) -> str: + salt_bytes = base64.b64decode(_pad_base64(salt)) + password_bytes = (CONTEXT_STRING_LOGIN + password).encode("utf-8") + hash_bytes = hash_secret_raw( + secret=password_bytes, + salt=salt_bytes, + time_cost=1, + memory_cost=131072, + parallelism=4, + hash_len=32, + type=Type.ID, + ) + hash_b64 = base64.b64encode(hash_bytes).decode("utf-8").rstrip("=") + return f"$argon2id$v=19$m=131072,t=1,p=4${salt}${hash_b64}" + + async def _get_salt(self, fmd_id: str) -> str: + return await self._make_api_request("PUT", "/api/v1/salt", {"IDT": fmd_id, "Data": ""}) + + async def _get_access_token(self, fmd_id: str, password_hash: str, session_duration: int) -> str: + payload = {"IDT": fmd_id, "Data": password_hash, "SessionDurationSeconds": session_duration} + return await self._make_api_request("PUT", "/api/v1/requestAccess", payload) + + async def _get_private_key_blob(self) -> str: + return await self._make_api_request("PUT", "/api/v1/key", {"IDT": self.access_token, "Data": "unused"}) + + def _decrypt_private_key_blob(self, key_b64: str, password: str) -> bytes: + key_bytes = base64.b64decode(_pad_base64(key_b64)) + salt = key_bytes[:ARGON2_SALT_LENGTH] + iv = key_bytes[ARGON2_SALT_LENGTH : ARGON2_SALT_LENGTH + AES_GCM_IV_SIZE_BYTES] + ciphertext = key_bytes[ARGON2_SALT_LENGTH + AES_GCM_IV_SIZE_BYTES :] + password_bytes = (CONTEXT_STRING_ASYM_KEY_WRAP + password).encode("utf-8") + aes_key = hash_secret_raw( + secret=password_bytes, salt=salt, time_cost=1, memory_cost=131072, parallelism=4, hash_len=32, type=Type.ID + ) + aesgcm = AESGCM(aes_key) + return aesgcm.decrypt(iv, ciphertext, None) + + def _load_private_key_from_bytes(self, privkey_bytes: bytes) -> RSAPrivateKey: + try: + return cast(RSAPrivateKey, serialization.load_pem_private_key(privkey_bytes, password=None)) + except ValueError: + return cast(RSAPrivateKey, serialization.load_der_private_key(privkey_bytes, password=None)) + + # ------------------------- + # Decryption + # ------------------------- + def decrypt_data_blob(self, data_b64: str) -> bytes: + """ + Decrypts a location or picture data blob using the instance's private key. + + Raises FmdApiException on problems (matches original behavior). + """ + blob = base64.b64decode(_pad_base64(data_b64)) + + # Check for minimum size (RSA packet + IV) + min_size = RSA_KEY_SIZE_BYTES + AES_GCM_IV_SIZE_BYTES + if len(blob) < min_size: + raise FmdApiException( + f"Blob too small for decryption: {len(blob)} bytes (expected at least {min_size} bytes). " + f"This may indicate empty/invalid data from the server." + ) + + session_key_packet = blob[:RSA_KEY_SIZE_BYTES] + iv = blob[RSA_KEY_SIZE_BYTES : RSA_KEY_SIZE_BYTES + AES_GCM_IV_SIZE_BYTES] + ciphertext = blob[RSA_KEY_SIZE_BYTES + AES_GCM_IV_SIZE_BYTES :] + key = self.private_key + if key is None: + raise FmdApiException("Private key not loaded. Call authenticate() first.") + session_key = key.decrypt( + session_key_packet, + padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None), + ) + aesgcm = AESGCM(session_key) + return aesgcm.decrypt(iv, ciphertext, None) + + # ------------------------- + # HTTP helper + # ------------------------- + async def _make_api_request( + self, + method: str, + endpoint: str, + payload: Any, + stream: bool = False, + expect_json: bool = True, + retry_auth: bool = True, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, + ): + """ + Makes an API request and returns Data or text depending on expect_json/stream. + Mirrors get_all_locations/_make_api_request logic from original file (including 401 re-auth). + """ + url = self.base_url + endpoint + await self._ensure_session() + session = self._session + assert session is not None # for type checker; ensured by _ensure_session + req_timeout = aiohttp.ClientTimeout(total=timeout if timeout is not None else self.timeout) + + # Determine retry policy + attempts_left = self.max_retries if max_retries is None else max(0, int(max_retries)) + + # Avoid unsafe retries for commands unless it's a 401 (handled separately) or 429 with Retry-After + is_command = endpoint.rstrip("/").endswith("/api/v1/command") + + backoff_attempt = 0 + while True: + try: + async with session.request(method, url, json=payload, timeout=req_timeout) as resp: + # Handle 401 -> re-authenticate once + if resp.status == 401 and retry_auth and self._fmd_id and self._password: + log.info("Received 401 Unauthorized, re-authenticating...") + await self.authenticate(self._fmd_id, self._password, self.session_duration) + payload["IDT"] = self.access_token + return await self._make_api_request( + method, + endpoint, + payload, + stream, + expect_json, + retry_auth=False, + timeout=timeout, + max_retries=attempts_left, + ) + + # Rate limit handling (429) + if resp.status == 429: + if attempts_left <= 0: + # Exhausted retries + body_text = await _safe_read_text(resp) + raise FmdApiException( + f"Rate limited (429) and retries exhausted. Body={body_text[:200] if body_text else ''}" + ) + retry_after = resp.headers.get("Retry-After") + delay = _parse_retry_after(retry_after) + if delay is None: + delay = _compute_backoff(self.backoff_base, backoff_attempt, self.backoff_max, self.jitter) + log.warning(f"Received 429 Too Many Requests. Sleeping {delay:.2f}s before retrying...") + attempts_left -= 1 + backoff_attempt += 1 + await asyncio.sleep(delay) + continue + + # Transient server errors -> retry (except for unsafe command POSTs) + if resp.status in (500, 502, 503, 504) and not ( + is_command and method.upper() == "POST" + ): + if attempts_left > 0: + delay = _compute_backoff( + self.backoff_base, backoff_attempt, self.backoff_max, self.jitter + ) + log.warning( + f"Server error {resp.status}. " + f"Retrying in {delay:.2f}s ({attempts_left} retries left)..." + ) + attempts_left -= 1 + backoff_attempt += 1 + await asyncio.sleep(delay) + continue + + # For all other statuses, raise for non-2xx + resp.raise_for_status() + + log.debug( + f"{endpoint} response - status: {resp.status}, " + f"content-type: {resp.content_type}, " + f"content-length: {resp.content_length}" + ) + + if not stream: + if expect_json: + # server sometimes reports wrong content-type -> force JSON parse + try: + json_data = await resp.json(content_type=None) + # Sanitize: don't log full JSON which may contain tokens/sensitive data + if log.isEnabledFor(logging.DEBUG): + # Log safe metadata only + keys = ( + list(json_data.keys()) + if isinstance(json_data, dict) + else "non-dict" + ) + log.debug(f"{endpoint} JSON response received with keys: {keys}") + return json_data["Data"] + except (KeyError, ValueError, json.JSONDecodeError) as e: + # fall back to text + log.debug(f"{endpoint} JSON parsing failed ({e}), trying as text") + text_data = await resp.text() + if text_data: + # Sanitize: avoid logging response bodies that may contain tokens + log.debug(f"{endpoint} text response received, length: {len(text_data)}") + else: + log.warning(f"{endpoint} returned EMPTY response body") + return text_data + else: + text_data = await resp.text() + log.debug(f"{endpoint} text response length: {len(text_data)}") + return text_data + else: + # Return the aiohttp response for streaming consumers + return resp + except aiohttp.ClientConnectionError as e: + # Transient connection issues -> retry if allowed (avoid unsafe command repeats) + if attempts_left > 0 and not (is_command and method.upper() == "POST"): + delay = _compute_backoff(self.backoff_base, backoff_attempt, self.backoff_max, self.jitter) + log.warning(f"Connection error calling {endpoint}: {e}. Retrying in {delay:.2f}s...") + attempts_left -= 1 + backoff_attempt += 1 + await asyncio.sleep(delay) + continue + log.error(f"API request failed for {endpoint}: {e}") + raise FmdApiException(f"API request failed for {endpoint}: {e}") from e + except aiohttp.ClientError as e: + log.error(f"API request failed for {endpoint}: {e}") + raise FmdApiException(f"API request failed for {endpoint}: {e}") from e + except (KeyError, ValueError) as e: + log.error(f"Failed to parse server response for {endpoint}: {e}") + raise FmdApiException(f"Failed to parse server response for {endpoint}: {e}") from e + + # ------------------------- + # Location / picture access + # ------------------------- + async def get_locations(self, num_to_get: int = -1, skip_empty: bool = True, max_attempts: int = 10) -> List[str]: + """ + Fetches all or the N most recent location blobs. + Returns list of base64-encoded blobs (strings), same as original get_all_locations. + """ + log.debug(f"Getting locations, num_to_get={num_to_get}, " f"skip_empty={skip_empty}") + size_str = await self._make_api_request( + "PUT", "/api/v1/locationDataSize", {"IDT": self.access_token, "Data": ""} + ) + size = int(size_str) + log.debug(f"Server reports {size} locations available") + if size == 0: + log.info("No locations found to download.") + return [] + + locations: List[str] = [] + if num_to_get == -1: + log.info(f"Found {size} locations to download.") + indices = range(size) + for i in indices: + log.info(f" - Downloading location at index {i}...") + blob = await self._make_api_request( + "PUT", "/api/v1/location", {"IDT": self.access_token, "Data": str(i)} + ) + locations.append(blob) + return locations + else: + num_to_download = min(num_to_get, size) + log.info(f"Found {size} locations. Downloading the {num_to_download} most recent.") + start_index = size - 1 + + if skip_empty: + indices = range(start_index, max(-1, start_index - max_attempts), -1) + log.info( + f"Will search for {num_to_download} non-empty location(s) " f"starting from index {start_index}" + ) + else: + end_index = size - num_to_download + indices = range(start_index, end_index - 1, -1) + log.info(f"Will fetch indices: {list(indices)}") + + for i in indices: + log.info(f" - Downloading location at index {i}...") + blob = await self._make_api_request("PUT", "/api/v1/location", {"IDT": self.access_token, "Data": str(i)}) + log.debug(f"Received blob type: {type(blob)}, length: {len(blob) if blob else 0}") + if blob and isinstance(blob, str) and blob.strip(): + log.debug(f"First 100 chars: {blob[:100]}") + locations.append(blob) + log.info(f"Found valid location at index {i}") + if len(locations) >= num_to_get and num_to_get != -1: + break + else: + log.warning(f"Empty blob received for location index {i}, repr: {repr(blob[:50] if blob else blob)}") + + if not locations and num_to_get != -1: + log.warning(f"No valid locations found after checking " f"{min(max_attempts, size)} indices") + + return locations + + async def get_pictures(self, num_to_get: int = -1, timeout: Optional[float] = None) -> List[Any]: + """Fetches all or the N most recent picture metadata blobs (raw server response).""" + req_timeout = aiohttp.ClientTimeout(total=timeout if timeout is not None else self.timeout) + try: + await self._ensure_session() + session = self._session + assert session is not None + async with session.put( + f"{self.base_url}/api/v1/pictures", json={"IDT": self.access_token, "Data": ""}, timeout=req_timeout + ) as resp: + resp.raise_for_status() + json_data = await resp.json() + # Extract the Data field if it exists, otherwise use the response as-is + all_pictures = json_data.get("Data", json_data) if isinstance(json_data, dict) else json_data + except aiohttp.ClientError as e: + log.warning(f"Failed to get pictures: {e}. The endpoint may not exist or requires a different method.") + return [] + + # Ensure all_pictures is a list + if not isinstance(all_pictures, list): + log.warning(f"Unexpected pictures response type: {type(all_pictures)}") + return [] + + if num_to_get == -1: + log.info(f"Found {len(all_pictures)} pictures to download.") + return all_pictures + else: + num_to_download = min(num_to_get, len(all_pictures)) + log.info(f"Found {len(all_pictures)} pictures. Selecting the {num_to_download} most recent.") + return all_pictures[-num_to_download:][::-1] + + async def export_data_zip(self, out_path: str, include_pictures: bool = True) -> str: + """ + Export all account data to a ZIP file (client-side packaging). + + This mimics the FMD web UI's export functionality by fetching all locations + and pictures via the existing API endpoints, decrypting them, and packaging + them into a user-friendly ZIP file. + + NOTE: There is no server-side /api/v1/exportData endpoint. This method + performs client-side data collection, decryption, and packaging, similar + to how the web UI implements its export feature. + + ZIP Contents: + - info.json: Export metadata (date, device ID, counts) + - locations.json: Decrypted location data (human-readable JSON) + - pictures/picture_NNNN.jpg: Extracted picture files + - pictures/manifest.json: Picture metadata (filename, size, index) + + Args: + out_path: Path where the ZIP file will be saved + include_pictures: Whether to include pictures in the export (default: True) + + Returns: + Path to the created ZIP file + + Raises: + FmdApiException: If data fetching or ZIP creation fails + """ + import zipfile + from datetime import datetime + + try: + log.info("Starting data export (client-side packaging)...") + + # Fetch all locations + log.info("Fetching all locations...") + location_blobs = await self.get_locations(num_to_get=-1, skip_empty=False) + + # Fetch all pictures if requested + picture_blobs = [] + if include_pictures: + log.info("Fetching all pictures...") + picture_blobs = await self.get_pictures(num_to_get=-1) + + # Create ZIP file with exported data + log.info(f"Creating export ZIP at {out_path}...") + with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zipf: + # Decrypt and add readable locations + decrypted_locations = [] + if location_blobs: + log.info(f"Decrypting {len(location_blobs)} locations...") + for i, blob in enumerate(location_blobs): + try: + decrypted = self.decrypt_data_blob(blob) + loc_data = json.loads(decrypted) + decrypted_locations.append(loc_data) + except Exception as e: + log.warning(f"Failed to decrypt location {i}: {e}") + decrypted_locations.append({"error": str(e), "index": i}) + + # Decrypt and extract pictures as image files + picture_file_list = [] + if picture_blobs: + log.info(f"Decrypting and extracting {len(picture_blobs)} pictures...") + for i, blob in enumerate(picture_blobs): + try: + decrypted = self.decrypt_data_blob(blob) + # Pictures are double-encoded: decrypt -> base64 string -> image bytes + inner_b64 = decrypted.decode("utf-8").strip() + from .helpers import b64_decode_padded + + image_bytes = b64_decode_padded(inner_b64) + + # Determine image format from magic bytes + if image_bytes.startswith(b"\xff\xd8\xff"): + ext = "jpg" + elif image_bytes.startswith(b"\x89PNG"): + ext = "png" + else: + ext = "jpg" # default to jpg + + filename = f"pictures/picture_{i:04d}.{ext}" + zipf.writestr(filename, image_bytes) + picture_file_list.append({"index": i, "filename": filename, "size": len(image_bytes)}) + + except Exception as e: + log.warning(f"Failed to decrypt/extract picture {i}: {e}") + picture_file_list.append({"index": i, "error": str(e)}) + + # Add metadata file (after processing so we have accurate counts) + export_info = { + "export_date": datetime.now().isoformat(), + "fmd_id": self._fmd_id, + "location_count": len(location_blobs), + "picture_count": len(picture_blobs), + "pictures_extracted": len([p for p in picture_file_list if "error" not in p]), + "version": "2.0", + } + zipf.writestr("info.json", json.dumps(export_info, indent=2)) + + # Add locations as readable JSON + if decrypted_locations: + zipf.writestr("locations.json", json.dumps(decrypted_locations, indent=2)) + + # Add picture manifest if we extracted any + if picture_file_list: + zipf.writestr("pictures/manifest.json", json.dumps(picture_file_list, indent=2)) + + log.info(f"Export completed successfully: {out_path}") + return out_path + + except Exception as e: + log.error(f"Failed to export data: {e}") + raise FmdApiException(f"Failed to export data: {e}") from e + + # ------------------------- + # Commands + # ------------------------- + async def send_command(self, command: str) -> bool: + """Sends a signed command to the server. Returns True on success.""" + log.info(f"Sending command to device: {command}") + unix_time_ms = int(time.time() * 1000) + message_to_sign = f"{unix_time_ms}:{command}" + message_bytes = message_to_sign.encode("utf-8") + key = self.private_key + if key is None: + raise FmdApiException("Private key not loaded. Call authenticate() first.") + signature = key.sign( + message_bytes, padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=32), hashes.SHA256() + ) + signature_b64 = base64.b64encode(signature).decode("utf-8").rstrip("=") + # Sanitize: don't log signature which could be replayed + + try: + await self._make_api_request( + "POST", + "/api/v1/command", + {"IDT": self.access_token, "Data": command, "UnixTime": unix_time_ms, "CmdSig": signature_b64}, + expect_json=False, + ) + log.info(f"Command sent successfully: {command}") + return True + except Exception as e: + log.error(f"Failed to send command '{command}': {e}") + raise FmdApiException(f"Failed to send command '{command}': {e}") from e + + async def request_location(self, provider: str = "all") -> bool: + provider_map = { + "all": "locate", + "gps": "locate gps", + "cell": "locate cell", + "network": "locate cell", + "last": "locate last", + } + command = provider_map.get(provider.lower(), "locate") + log.info(f"Requesting location update with provider: {provider} (command: {command})") + return await self.send_command(command) + + async def set_bluetooth(self, enable: bool) -> bool: + """Set Bluetooth power explicitly: True = on, False = off.""" + command = "bluetooth on" if enable else "bluetooth off" + log.info(f"{'Enabling' if enable else 'Disabling'} Bluetooth") + return await self.send_command(command) + + async def set_do_not_disturb(self, enable: bool) -> bool: + """Set Do Not Disturb explicitly: True = on, False = off.""" + command = "nodisturb on" if enable else "nodisturb off" + log.info(f"{'Enabling' if enable else 'Disabling'} Do Not Disturb mode") + return await self.send_command(command) + + async def set_ringer_mode(self, mode: str) -> bool: + mode = mode.lower() + mode_map = {"normal": "ringermode normal", "vibrate": "ringermode vibrate", "silent": "ringermode silent"} + if mode not in mode_map: + raise ValueError(f"Invalid ringer mode '{mode}'. Must be 'normal', 'vibrate', or 'silent'") + command = mode_map[mode] + log.info(f"Setting ringer mode to: {mode}") + return await self.send_command(command) + + async def get_device_stats(self) -> bool: + log.info("Requesting device network statistics") + return await self.send_command("stats") + + async def take_picture(self, camera: str = "back") -> bool: + camera = camera.lower() + if camera not in ["front", "back"]: + raise ValueError(f"Invalid camera '{camera}'. Must be 'front' or 'back'") + command = "camera front" if camera == "front" else "camera back" + log.info(f"Requesting picture from {camera} camera") + return await self.send_command(command) + + +# ------------------------- +# Internal helpers for retry/backoff and logging (module-level) +# ------------------------- +def _mask_token(token: Optional[str], show_chars: int = 8) -> str: + """Mask sensitive tokens for logging, showing only first N chars.""" + if not token: + return "" + if len(token) <= show_chars: + return "***" + return f"{token[:show_chars]}...***" + + +def _compute_backoff(base: float, attempt: int, max_delay: float, jitter: bool) -> float: + delay = min(max_delay, base * (2**attempt)) + if jitter: + # Full jitter: random between 0 and delay + return random.uniform(0, delay) + return delay + + +def _parse_retry_after(retry_after_header: Optional[str]) -> Optional[float]: + """Parse Retry-After header. Supports seconds; returns None if not usable.""" + if not retry_after_header: + return None + try: + seconds = int(retry_after_header.strip()) + if seconds < 0: + return None + return float(seconds) + except Exception: + # Parsing HTTP-date would require email.utils; skip and return None + return None + + +async def _safe_read_text(resp: aiohttp.ClientResponse) -> Optional[str]: + try: + return await resp.text() + except Exception: + return None diff --git a/fmd_api/device.py b/fmd_api/device.py new file mode 100644 index 0000000..2ad8fbe --- /dev/null +++ b/fmd_api/device.py @@ -0,0 +1,149 @@ +"""Device class representing a single tracked device. + +Device implements small helpers that call into FmdClient to perform the same +operations available in the original module (get locations, take pictures, send commands). +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Optional, AsyncIterator, List, Dict, Any + +from .models import Location, PhotoResult +from .exceptions import OperationError +from .helpers import b64_decode_padded +from .client import FmdClient + + +def _parse_location_blob(blob_b64: str) -> Location: + """Helper to decrypt and parse a location blob into Location dataclass.""" + # This function expects the caller to pass in a client to decrypt; kept here + # for signature clarity in Device methods. + raise RuntimeError("Internal: _parse_location_blob should not be called directly") + + +class Device: + def __init__(self, client: FmdClient, fmd_id: str, raw: Optional[Dict[str, Any]] = None): + self.client = client + self.id = fmd_id + self.raw: Dict[str, Any] = raw or {} + self.name = self.raw.get("name") + self.cached_location: Optional[Location] = None + self._last_refresh = None + + async def refresh(self, *, force: bool = False): + """Refresh the device's most recent location (uses client.get_locations(1)).""" + if not force and self.cached_location is not None: + return + + blobs = await self.client.get_locations(num_to_get=1) + if not blobs: + self.cached_location = None + return + + # decrypt and parse JSON + decrypted = self.client.decrypt_data_blob(blobs[0]) + loc = json.loads(decrypted) + # Build Location object with fields from README / fmd_api.py + timestamp_ms = loc.get("date") + ts = datetime.fromtimestamp(timestamp_ms / 1000.0, tz=timezone.utc) if timestamp_ms else None + self.cached_location = Location( + lat=loc["lat"], + lon=loc["lon"], + timestamp=ts, + accuracy_m=loc.get("accuracy"), + altitude_m=loc.get("altitude"), + speed_m_s=loc.get("speed"), + heading_deg=loc.get("heading"), + battery_pct=loc.get("bat"), + provider=loc.get("provider"), + raw=loc, + ) + + async def get_location(self, *, force: bool = False) -> Optional[Location]: + if force or self.cached_location is None: + await self.refresh(force=force) + return self.cached_location + + async def get_history(self, start=None, end=None, limit: int = -1) -> AsyncIterator[Location]: + """ + Iterate historical locations. Uses client.get_locations() under the hood. + Yields decrypted Location objects newest-first (matches get_all_locations when requesting N recent). + """ + # For parity with original behavior, we request num_to_get=limit when limit!=-1, + # otherwise request all and stream. + if limit == -1: + blobs = await self.client.get_locations(-1) + else: + blobs = await self.client.get_locations(limit, skip_empty=True) + + for b in blobs: + try: + decrypted = self.client.decrypt_data_blob(b) + loc = json.loads(decrypted) + timestamp_ms = loc.get("date") + ts = datetime.fromtimestamp(timestamp_ms / 1000.0, tz=timezone.utc) if timestamp_ms else None + yield Location( + lat=loc["lat"], + lon=loc["lon"], + timestamp=ts, + accuracy_m=loc.get("accuracy"), + altitude_m=loc.get("altitude"), + speed_m_s=loc.get("speed"), + heading_deg=loc.get("heading"), + battery_pct=loc.get("bat"), + provider=loc.get("provider"), + raw=loc, + ) + except Exception as e: + # skip invalid blobs but log + raise OperationError(f"Failed to decrypt/parse location blob: {e}") from e + + async def play_sound(self) -> bool: + return await self.client.send_command("ring") + + async def take_front_photo(self) -> bool: + return await self.client.take_picture("front") + + async def take_rear_photo(self) -> bool: + return await self.client.take_picture("back") + + async def fetch_pictures(self, num_to_get: int = -1) -> List[dict]: + return await self.client.get_pictures(num_to_get=num_to_get) + + async def download_photo(self, picture_blob_b64: str) -> PhotoResult: + """ + Decrypt a picture blob and return binary PhotoResult. + + The fmd README says picture data is double-encoded: encrypted blob -> base64 string -> image bytes. + We decrypt the blob to get a base64-encoded image string; decode that to bytes and return. + """ + decrypted = self.client.decrypt_data_blob(picture_blob_b64) + # decrypted is bytes, often containing a base64-encoded image (as text) + try: + inner_b64 = decrypted.decode("utf-8").strip() + image_bytes = b64_decode_padded(inner_b64) + # timestamp is not standardized in picture payload; attempt to parse JSON if present + raw_meta = None + try: + raw_meta = json.loads(decrypted) + except Exception: + raw_meta = {"note": "binary image or base64 string; no JSON metadata"} + # Build PhotoResult; mime type not provided by server so default to image/jpeg + return PhotoResult( + data=image_bytes, mime_type="image/jpeg", timestamp=datetime.now(timezone.utc), raw=raw_meta + ) + except Exception as e: + raise OperationError(f"Failed to decode picture blob: {e}") from e + + async def lock(self, message: Optional[str] = None, passcode: Optional[str] = None) -> bool: + # The original API supports "lock" command; it does not carry message/passcode in the current client + # Implementation preserves original behavior (sends "lock" command). + # Extensions can append data if server supports it. + return await self.client.send_command("lock") + + async def wipe(self, confirm: bool = False) -> bool: + if not confirm: + raise OperationError("wipe() requires confirm=True to proceed (destructive action)") + return await self.client.send_command("delete") diff --git a/fmd_api/exceptions.py b/fmd_api/exceptions.py new file mode 100644 index 0000000..63a50c8 --- /dev/null +++ b/fmd_api/exceptions.py @@ -0,0 +1,31 @@ +"""Typed exceptions for fmd_api v2.""" + + +class FmdApiException(Exception): + """Base exception for FMD API errors.""" + + pass + + +class AuthenticationError(FmdApiException): + """Raised when authentication fails.""" + + pass + + +class DeviceNotFoundError(FmdApiException): + """Raised when a requested device cannot be found.""" + + pass + + +class RateLimitError(FmdApiException): + """Raised when the server indicates rate limiting.""" + + pass + + +class OperationError(FmdApiException): + """Raised for failed operations (commands, downloads, etc).""" + + pass diff --git a/fmd_api/helpers.py b/fmd_api/helpers.py new file mode 100644 index 0000000..05e087c --- /dev/null +++ b/fmd_api/helpers.py @@ -0,0 +1,14 @@ +"""Small helper utilities.""" + +import base64 + + +def _pad_base64(s: str) -> str: + return s + "=" * (-len(s) % 4) + + +def b64_decode_padded(s: str) -> bytes: + return base64.b64decode(_pad_base64(s)) + + +# Placeholder for pagination helpers, parse helpers, etc. diff --git a/fmd_api/models.py b/fmd_api/models.py new file mode 100644 index 0000000..c3ed685 --- /dev/null +++ b/fmd_api/models.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional, Dict, Any + + +@dataclass +class Location: + lat: float + lon: float + timestamp: Optional[datetime] + accuracy_m: Optional[float] = None + altitude_m: Optional[float] = None + speed_m_s: Optional[float] = None + heading_deg: Optional[float] = None + battery_pct: Optional[int] = None + provider: Optional[str] = None + raw: Optional[Dict[str, Any]] = None + + +@dataclass +class PhotoResult: + data: bytes + mime_type: str + timestamp: datetime + raw: Optional[Dict[str, Any]] = None diff --git a/fmd_api/py.typed b/fmd_api/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/fmd_client.py b/fmd_client.py deleted file mode 100644 index ff0a4b3..0000000 --- a/fmd_client.py +++ /dev/null @@ -1,175 +0,0 @@ -""" -FMD Server Client - -This is the main client script for interacting with the FMD server. -It can export locations and pictures to a local directory or zip file. - -Usage: - python fmd_client.py --url --id --password --output [--locations [N]] [--pictures [N]] - -Dependencies: - pip install aiohttp argon2-cffi cryptography -""" -import argparse -import asyncio -import base64 -import sys -import os -import zipfile -import json -from fmd_api import FmdApi, _pad_base64 - -def save_locations_csv(api, location_blobs, out_path): - header = "Date,Provider,Battery %,Latitude,Longitude,Accuracy (m),Altitude (m),Speed (m/s),Heading (°)\n" - lines = [header] - skipped_count = 0 - for idx, location_blob in enumerate(location_blobs): - loc = None - try: - decrypted_bytes = api.decrypt_data_blob(location_blob) - loc = json.loads(decrypted_bytes) - except Exception as e: - skipped_count += 1 - print(f"Warning: Skipping location {idx} - {e}") - continue - - if not loc: - continue - - date = loc.get('time', 'N/A') - provider = loc.get('provider', 'N/A') - bat = loc.get('bat', 'N/A') - lat = loc.get('lat', 'N/A') - lon = loc.get('lon', 'N/A') - accuracy = loc.get('accuracy', 'N/A') - altitude = loc.get('altitude', 'N/A') - speed = loc.get('speed', 'N/A') - heading = loc.get('heading', 'N/A') # Changed from 'bearing' to 'heading' - lines.append(f"{date},{provider},{bat},{lat},{lon},{accuracy},{altitude},{speed},{heading}\n") - - with open(out_path, 'w', encoding='utf-8') as f: - f.writelines(lines) - - if skipped_count > 0: - print(f"Note: Skipped {skipped_count} invalid/empty location(s) out of {len(location_blobs)} total") - -def save_pictures(api, picture_blobs, out_dir): - os.makedirs(out_dir, exist_ok=True) - skipped_count = 0 - for idx, pic_blob in enumerate(picture_blobs): - try: - if pic_blob: - decrypted_payload_bytes = api.decrypt_data_blob(pic_blob) - # The decrypted payload is likely a base64 string, possibly with a data URI prefix. - decrypted_text = decrypted_payload_bytes.decode('utf-8') - base64_data = decrypted_text.split(',')[-1] - image_bytes = base64.b64decode(_pad_base64(base64_data)) - with open(os.path.join(out_dir, f"{idx}.png"), 'wb') as f: - f.write(image_bytes) - except Exception as e: - skipped_count += 1 - print(f"Warning: Skipping picture {idx} - {e}") - - if skipped_count > 0: - print(f"Note: Skipped {skipped_count} invalid/empty picture(s) out of {len(picture_blobs)} total") - -async def main(): - parser = argparse.ArgumentParser(description="FMD Server Client") - parser.add_argument('--url', required=True, help='Base URL of the FMD server (e.g. https://fmd.example.com)') - parser.add_argument('--id', required=True, help='FMD ID (username)') - parser.add_argument('--password', required=True, help='Password') - parser.add_argument('--output', required=True, help='Output .zip file or directory') - parser.add_argument('--locations', nargs='?', const=-1, default=None, type=int, help='Include all locations, or specify a number for the most recent N locations.') - parser.add_argument('--pictures', nargs='?', const=-1, default=None, type=int, help='Include all pictures, or specify a number for the most recent N pictures.') - parser.add_argument('--session', type=int, default=3600, help='Session duration in seconds (default: 3600)') - args = parser.parse_args() - - base_url = args.url.rstrip('/') - fmd_id = args.id - password = args.password - session_duration = args.session - output_path = args.output - num_locations_to_get = args.locations - num_pictures_to_get = args.pictures - - if num_locations_to_get is None and num_pictures_to_get is None: - print("Nothing to export: specify --locations and/or --pictures") - sys.exit(1) - - print("[1-3] Authenticating and retrieving keys...") - api = await FmdApi.create(base_url, fmd_id, password, session_duration) - - locations_json = None - pictures_json = None - if num_locations_to_get is not None: - print("[4] Downloading locations...") - locations_json = await api.get_all_locations(num_locations_to_get) - if num_pictures_to_get is not None: - print("[5] Downloading pictures...") - pictures_json = await api.get_pictures(num_pictures_to_get) - - is_zip = output_path.lower().endswith('.zip') - if is_zip: - print(f"[6] Writing to zip: {output_path}") - skipped_locations = 0 - skipped_pictures = 0 - with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf: - if num_locations_to_get is not None and locations_json is not None: - import io - csv_buf = io.StringIO() - header = "Date,Provider,Battery %,Latitude,Longitude,Accuracy (m),Altitude (m),Speed (m/s),Heading (°)\n" - csv_buf.write(header) - location_list = locations_json - for idx, location_blob in enumerate(location_list): - if not location_blob: continue - loc = None - try: - decrypted_bytes = api.decrypt_data_blob(location_blob) - loc = json.loads(decrypted_bytes) - except Exception as e: - skipped_locations += 1 - print(f"Warning: Skipping location {idx} for zip - {e}") - continue - - if not loc: - continue - date = loc.get('time', 'N/A') - provider = loc.get('provider', 'N/A') - bat = loc.get('bat', 'N/A') - lat = loc.get('lat', 'N/A') - lon = loc.get('lon', 'N/A') - accuracy = loc.get('accuracy', 'N/A') - altitude = loc.get('altitude', 'N/A') - speed = loc.get('speed', 'N/A') - heading = loc.get('heading', 'N/A') # Changed from 'bearing' to 'heading' - csv_buf.write(f"{date},{provider},{bat},{lat},{lon},{accuracy},{altitude},{speed},{heading}\n") - zf.writestr('locations.csv', csv_buf.getvalue()) - if skipped_locations > 0: - print(f"Note: Skipped {skipped_locations} invalid/empty location(s) in zip") - if num_pictures_to_get is not None and pictures_json is not None: - picture_list = pictures_json - for idx, pic_blob in enumerate(picture_list): - if pic_blob: - try: - decrypted_payload_bytes = api.decrypt_data_blob(pic_blob) - decrypted_text = decrypted_payload_bytes.decode('utf-8') - base64_data = decrypted_text.split(',')[-1] - image_bytes = base64.b64decode(_pad_base64(base64_data)) - zf.writestr(f'pictures/{idx}.png', image_bytes) - except Exception as e: - skipped_pictures += 1 - print(f"Warning: Skipping picture {idx} for zip - {e}") - if skipped_pictures > 0: - print(f"Note: Skipped {skipped_pictures} invalid/empty picture(s) in zip") - print(f"Exported data saved to {output_path}") - else: - print(f"[6] Writing to directory: {output_path}") - os.makedirs(output_path, exist_ok=True) - if num_locations_to_get is not None and locations_json is not None: - save_locations_csv(api, locations_json, os.path.join(output_path, 'locations.csv')) - if num_pictures_to_get is not None and pictures_json is not None: - save_pictures(api, pictures_json, os.path.join(output_path, 'pictures')) - print(f"Exported data saved to {output_path}") - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 4c64160..fdfd681 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,12 @@ -[build-system] -requires = ["setuptools>=61.0", "wheel"] -build-backend = "setuptools.build_meta" - [project] name = "fmd_api" -version = "0.1.0" -authors = [ - {name = "Devin Slick", email = "fmd_client_github@devinslick.com"}, -] -description = "A Python client for the FMD server API" +version = "2.0.1" +authors = [{name = "devinslick"}] +description = "A Python client for the FMD (Find My Device) server API" readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" classifiers = [ "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -21,19 +14,22 @@ classifiers = [ "Programming Language :: Python :: 3.12", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Monitoring", ] keywords = ["fmd", "find-my-device", "location", "tracking", "device-tracking", "api-client"] dependencies = [ - "requests", "argon2-cffi", "cryptography", "aiohttp", ] +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + [project.urls] Homepage = "https://github.com/devinslick/fmd_api" Repository = "https://github.com/devinslick/fmd_api" @@ -44,13 +40,19 @@ Documentation = "https://github.com/devinslick/fmd_api#readme" dev = [ "pytest>=7.0", "pytest-asyncio", + "pytest-cov", + "aioresponses>=0.7.0", "black", "flake8", "mypy", ] -[tool.setuptools] -py-modules = ["fmd_api"] +# --- IMPORTANT CHANGE --- +# Use setuptools.find to include the package directory (fmd_api/) in built distributions. +# Do NOT use py-modules = ["fmd_api"] which causes packaging a single fmd_api.py module. +[tool.setuptools.packages.find] +where = ["."] +exclude = ["tests*", "debugging*"] [tool.black] line-length = 120 @@ -62,3 +64,17 @@ python_files = "test_*.py" python_classes = "Test*" python_functions = "test_*" asyncio_mode = "auto" + +[tool.coverage.run] +source = ["fmd_api"] +omit = ["tests/*", "debugging/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 2287829..0000000 --- a/setup.py +++ /dev/null @@ -1,24 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name="fmd_api", - version="0.1.0", - author="Devin Slick", - author_email="fmd_api_github@devinslick.com", - description="A python client for the FMD server API.", - long_description=open('README.md').read(), - long_description_content_type="text/markdown", - url="https://github.com/devinslick/fmd_api", - py_modules=["fmd_api"], - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - ], - python_requires='>=3.7', - install_requires=[ - "requests", - "argon2-cffi", - "cryptography", - ], -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..4d07f05 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for fmd_api diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cb7dc97 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +# pytest configuration for fmd_api tests +import sys +from pathlib import Path + +# Ensure the package root is in sys.path for proper imports +repo_root = Path(__file__).parent.parent +if str(repo_root) not in sys.path: + sys.path.insert(0, str(repo_root)) diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py new file mode 100644 index 0000000..42a319f --- /dev/null +++ b/tests/functional/__init__.py @@ -0,0 +1 @@ +# Functional tests for fmd_api (require credentials) diff --git a/tests/functional/test_auth.py b/tests/functional/test_auth.py new file mode 100644 index 0000000..7e8a851 --- /dev/null +++ b/tests/functional/test_auth.py @@ -0,0 +1,33 @@ +""" +Test: authenticate (FmdClient.create) +Usage: + python tests/functional/test_auth.py +""" + +import asyncio +import sys +from pathlib import Path + +# Add repo root to path for package imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from tests.utils.read_credentials import read_credentials + + +async def main(): + creds = read_credentials() + if not creds.get("BASE_URL") or not creds.get("FMD_ID") or not creds.get("PASSWORD"): + print( + "Missing credentials. Copy tests/utils/credentials.txt.example -> " + "tests/utils/credentials.txt and fill in BASE_URL, FMD_ID, PASSWORD" + ) + return + from fmd_api import FmdClient + + client = await FmdClient.create(creds["BASE_URL"], creds["FMD_ID"], creds["PASSWORD"]) + print("Authenticated. access_token (first 12 chars):", (client.access_token or "")[:12]) + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/functional/test_commands.py b/tests/functional/test_commands.py new file mode 100644 index 0000000..3448919 --- /dev/null +++ b/tests/functional/test_commands.py @@ -0,0 +1,115 @@ +""" +Test: Validated command methods (ring, lock, camera, bluetooth, etc.) +Usage: + python tests/functional/test_commands.py [args...] + +Commands: + ring - Make device ring + lock - Lock device screen + camera - Take picture (default: back) + bluetooth - Set Bluetooth on/off + dnd - Set Do Not Disturb on/off + ringer - Set ringer mode + stats - Get device network statistics + locate [all|gps|cell|last] - Request location update (default: all) + +Examples: + python tests/functional/test_commands.py ring + python tests/functional/test_commands.py camera front + python tests/functional/test_commands.py bluetooth on + python tests/functional/test_commands.py ringer vibrate + python tests/functional/test_commands.py locate gps +""" + +import asyncio +import sys +from pathlib import Path + +# Add repo root to path for package imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from tests.utils.read_credentials import read_credentials + + +async def main(): + creds = read_credentials() + if not creds.get("BASE_URL") or not creds.get("FMD_ID") or not creds.get("PASSWORD"): + print("Missing credentials.") + return + + if len(sys.argv) < 2: + print(__doc__) + return + + command = sys.argv[1].lower() + from fmd_api import FmdClient + + client = await FmdClient.create(creds["BASE_URL"], creds["FMD_ID"], creds["PASSWORD"]) + + try: + result = False + + if command == "ring": + result = await client.send_command("ring") + print(f"Ring command sent: {result}") + + elif command == "lock": + result = await client.send_command("lock") + print(f"Lock command sent: {result}") + + elif command == "camera": + camera = sys.argv[2].lower() if len(sys.argv) > 2 else "back" + result = await client.take_picture(camera) + print(f"Camera '{camera}' command sent: {result}") + + elif command == "bluetooth": + if len(sys.argv) < 3: + print("Error: bluetooth requires on|off argument") + return + state = sys.argv[2].lower() + if state not in ["on", "off"]: + print("Error: bluetooth state must be 'on' or 'off'") + return + result = await client.set_bluetooth(state == "on") + print(f"Bluetooth {state} command sent: {result}") + + elif command == "dnd": + if len(sys.argv) < 3: + print("Error: dnd requires on|off argument") + return + state = sys.argv[2].lower() + if state not in ["on", "off"]: + print("Error: dnd state must be 'on' or 'off'") + return + result = await client.set_do_not_disturb(state == "on") + print(f"Do Not Disturb {state} command sent: {result}") + + elif command == "ringer": + if len(sys.argv) < 3: + print("Error: ringer requires normal|vibrate|silent argument") + return + mode = sys.argv[2].lower() + result = await client.set_ringer_mode(mode) + print(f"Ringer mode '{mode}' command sent: {result}") + + elif command == "stats": + result = await client.get_device_stats() + print(f"Device stats command sent: {result}") + + elif command == "locate": + provider = sys.argv[2].lower() if len(sys.argv) > 2 else "all" + result = await client.request_location(provider) + print(f"Location request ({provider}) sent: {result}") + + else: + print(f"Unknown command: {command}") + print(__doc__) + + except ValueError as e: + print(f"Error: {e}") + finally: + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/functional/test_device.py b/tests/functional/test_device.py new file mode 100644 index 0000000..13b9cd5 --- /dev/null +++ b/tests/functional/test_device.py @@ -0,0 +1,51 @@ +""" +Test: Device class flows (refresh, get_location, fetch_pictures, download_photo) +Usage: + python tests/functional/test_device.py +""" + +import asyncio +import sys +from pathlib import Path + +# Add repo root to path for package imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from tests.utils.read_credentials import read_credentials + + +async def main(): + creds = read_credentials() + if not creds.get("BASE_URL") or not creds.get("FMD_ID") or not creds.get("PASSWORD"): + print("Missing credentials.") + return + device_id = creds.get("DEVICE_ID", creds.get("FMD_ID")) + + from fmd_api import FmdClient + from fmd_api.device import Device + + client = await FmdClient.create(creds["BASE_URL"], creds["FMD_ID"], creds["PASSWORD"]) + try: + device = Device(client, device_id) + print("Refreshing device (may return nothing if no data)...") + await device.refresh() + loc = await device.get_location() + print("Cached location:", loc) + # fetch pictures and attempt to download the first one + pics = await device.fetch_pictures(5) + print("Pictures listed:", len(pics)) + if pics: + try: + photo = await device.download_photo(pics[0]) + fn = "device_photo.jpg" + with open(fn, "wb") as f: + f.write(photo.data) + print("Saved device photo to", fn) + except Exception as e: + print("Failed to download photo:", e) + finally: + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/functional/test_export.py b/tests/functional/test_export.py new file mode 100644 index 0000000..a12d507 --- /dev/null +++ b/tests/functional/test_export.py @@ -0,0 +1,34 @@ +""" +Test: export_data_zip (downloads export ZIP to provided filename) +Usage: + python tests/functional/test_export.py [output.zip] +""" + +import asyncio +import sys +from pathlib import Path + +# Add repo root to path for package imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from tests.utils.read_credentials import read_credentials + + +async def main(): + creds = read_credentials() + if not creds.get("BASE_URL") or not creds.get("FMD_ID") or not creds.get("PASSWORD"): + print("Missing credentials.") + return + out = sys.argv[1] if len(sys.argv) > 1 else "export_test.zip" + from fmd_api import FmdClient + + client = await FmdClient.create(creds["BASE_URL"], creds["FMD_ID"], creds["PASSWORD"]) + try: + await client.export_data_zip(out) + print("Export saved to", out) + finally: + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/functional/test_locations.py b/tests/functional/test_locations.py new file mode 100644 index 0000000..1c24459 --- /dev/null +++ b/tests/functional/test_locations.py @@ -0,0 +1,49 @@ +""" +Test: get_locations + decrypt_data_blob +Fetch most recent N blobs and decrypt each (prints parsed JSON). +Usage: + python tests/functional/test_locations.py [N] +""" + +import asyncio +import json +import sys +from pathlib import Path + +# Add repo root to path for package imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from tests.utils.read_credentials import read_credentials + + +async def main(): + creds = read_credentials() + if not creds.get("BASE_URL") or not creds.get("FMD_ID") or not creds.get("PASSWORD"): + print("Missing credentials.") + return + num = -1 + if len(sys.argv) > 1: + try: + num = int(sys.argv[1]) + except (ValueError, TypeError): + # Ignore invalid CLI value; keep default of -1 (fetch all) + num = -1 + from fmd_api import FmdClient + + client = await FmdClient.create(creds["BASE_URL"], creds["FMD_ID"], creds["PASSWORD"]) + try: + blobs = await client.get_locations(num_to_get=num if num != 0 else -1) + print(f"Retrieved {len(blobs)} location blob(s)") + for i, b in enumerate(blobs[:10]): + try: + dec = client.decrypt_data_blob(b) + obj = json.loads(dec) + print(f"Blob #{i}: {json.dumps(obj, indent=2)}") + except Exception as e: + print(f"Failed to decrypt/parse blob #{i}: {e}") + finally: + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/functional/test_pictures.py b/tests/functional/test_pictures.py new file mode 100644 index 0000000..ff986d8 --- /dev/null +++ b/tests/functional/test_pictures.py @@ -0,0 +1,71 @@ +""" +Test: get_pictures and download/decrypt the first picture found +Usage: + python tests/functional/test_pictures.py +""" + +import asyncio +import base64 +import sys +from pathlib import Path + +# Add repo root to path for package imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from tests.utils.read_credentials import read_credentials + + +async def main(): + creds = read_credentials() + if not creds.get("BASE_URL") or not creds.get("FMD_ID") or not creds.get("PASSWORD"): + print("Missing credentials.") + return + + from fmd_api import FmdClient + + client = await FmdClient.create(creds["BASE_URL"], creds["FMD_ID"], creds["PASSWORD"]) + try: + pics = await client.get_pictures(10) + print("Pictures returned:", len(pics)) + if not pics: + print("No pictures available.") + return + + # Server sometimes returns list of dicts or list of base64 strings. + # Try to extract a blob string: + first = pics[0] + blob = None + if isinstance(first, dict): + # try common keys + for k in ("Data", "blob", "Blob", "data"): + if k in first: + blob = first[k] + break + # if picture metadata contains an encoded blob in a nested field, adjust as needed + elif isinstance(first, str): + blob = first + + if not blob: + print("Could not find picture blob inside first picture entry. Showing entry:") + print(first) + return + + # decrypt to get inner base64 image string or bytes + decrypted = client.decrypt_data_blob(blob) + try: + inner_b64 = decrypted.decode("utf-8").strip() + img = base64.b64decode(inner_b64 + "=" * (-len(inner_b64) % 4)) + out = "picture_0.jpg" + with open(out, "wb") as f: + f.write(img) + print("Saved picture to", out) + except Exception: + print("Decrypted payload not a base64 image string; saving raw bytes as picture_0.bin") + with open("picture_0.bin", "wb") as f: + f.write(decrypted) + finally: + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/functional/test_request_location.py b/tests/functional/test_request_location.py new file mode 100644 index 0000000..1feadd6 --- /dev/null +++ b/tests/functional/test_request_location.py @@ -0,0 +1,47 @@ +""" +Test: request a new location command and then poll for the latest location. +Usage: + python tests/functional/test_request_location.py [provider] [wait_seconds] +provider: one of all,gps,cell,last (default: all) +wait_seconds: seconds to wait for the device to respond (default: 30) +""" + +import asyncio +import json +import sys +from pathlib import Path + +# Add repo root to path for package imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from tests.utils.read_credentials import read_credentials + + +async def main(): + creds = read_credentials() + if not creds.get("BASE_URL") or not creds.get("FMD_ID") or not creds.get("PASSWORD"): + print("Missing credentials.") + return + provider = sys.argv[1] if len(sys.argv) > 1 else "all" + wait = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + + from fmd_api import FmdClient + + client = await FmdClient.create(creds["BASE_URL"], creds["FMD_ID"], creds["PASSWORD"]) + try: + ok = await client.request_location(provider) + print("Request location sent:", ok) + if ok and wait > 0: + print(f"Waiting {wait} seconds for device to upload...") + await asyncio.sleep(wait) + blobs = await client.get_locations(5) + print("Recent blobs:", len(blobs)) + if blobs: + dec = client.decrypt_data_blob(blobs[0]) + print("Newest decrypted:", json.dumps(json.loads(dec), indent=2)) + finally: + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..63edb6f --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +# Unit tests for fmd_api diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py new file mode 100644 index 0000000..b5e64bb --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,1029 @@ +import json +import base64 + +import pytest +from aioresponses import aioresponses + +from fmd_api.client import FmdClient + +# NOTE: These tests validate behavior parity for the core HTTP flows using mocks. +# They do not perform full Argon2/RSA cryptography verification, but they assert +# that the client calls the expected endpoints and behaves like the original client. + + +@pytest.mark.asyncio +async def test_get_locations_and_decrypt(monkeypatch): + # Create a fake client and stub methods that require heavy crypto with small helpers. + client = FmdClient("https://fmd.example.com") + # Provide a dummy private_key with a decrypt method for testing + + class DummyKey: + def decrypt(self, packet, padding_obj): + # Return a 32-byte AES session key for AESGCM, for tests we use 32 zero bytes + return b"\x00" * 32 + + client.private_key = DummyKey() + + # Build a fake AES-GCM encrypted payload: we'll create plaintext b'{"lat":1.0,"lon":2.0,"date":1234,"bat":50}' + plaintext = b'{"lat":1.0,"lon":2.0,"date":1600000000000,"bat":50}' + # For the test, simulate AESGCM by encrypting with a known key using AESGCM class + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + iv = b"\x01" * 12 + ciphertext = aesgcm.encrypt(iv, plaintext, None) + # Build blob: session_key_packet (RSA_KEY_SIZE_BYTES) + iv + ciphertext + session_key_packet = b"\xaa" * 384 # dummy RSA packet; DummyKey.decrypt ignores it + blob = session_key_packet + iv + ciphertext + blob_b64 = base64.b64encode(blob).decode("utf-8").rstrip("=") + + # Mock the endpoints used by get_locations: + client.access_token = "dummy-token" + # Ensure session is created before entering aioresponses context + await client._ensure_session() + + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64}) + try: + locations = await client.get_locations(num_to_get=1) + assert len(locations) == 1 + decrypted = client.decrypt_data_blob(locations[0]) + assert b'"lat":1.0' in decrypted + assert b'"lon":2.0' in decrypted + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_connector_configuration_applied(): + """Client should apply SSL and pooling settings to the connector.""" + import aiohttp + + client = FmdClient( + "https://fmd.example.com", + ssl=False, # disable verification + conn_limit=10, + conn_limit_per_host=5, + keepalive_timeout=15.0, + ) + + try: + await client._ensure_session() + # Ensure session/connector created + assert client._session is not None + connector = client._session.connector + assert isinstance(connector, aiohttp.TCPConnector) + + # Validate SSL disabled (private attr in aiohttp) + assert getattr(connector, "_ssl", None) is False + + # Validate limits (use properties when available; fall back to private attrs) + limit = getattr(connector, "limit", None) + if limit is None: + limit = getattr(connector, "_limit", None) + assert limit == 10 + + lph = getattr(connector, "limit_per_host", None) + if lph is None: + lph = getattr(connector, "_limit_per_host", None) + assert lph == 5 + + # Validate keepalive timeout (private in aiohttp) + kat = getattr(connector, "keepalive_timeout", None) + if kat is None: + kat = getattr(connector, "_keepalive_timeout", None) + # Some aiohttp versions may store as int or float; compare as float + assert pytest.approx(float(kat)) == 15.0 + finally: + await client.close() + + +def test_https_required(): + """FmdClient should reject non-HTTPS base URLs.""" + with pytest.raises(ValueError, match="HTTPS is required"): + FmdClient("http://fmd.example.com") + + +@pytest.mark.asyncio +async def test_create_closes_session_on_auth_failure(monkeypatch): + """FmdClient.create should close any created session if auth fails; avoid subclassing ClientSession.""" + import aiohttp + from fmd_api.exceptions import FmdApiException + + closed = {"count": 0} + + real_cls = aiohttp.ClientSession + + def factory(*args, **kwargs): + # Create a real session, then wrap its close method to track calls + sess = real_cls(*args, **kwargs) + real_close = sess.close + + async def tracked_close(): + closed["count"] += 1 + await real_close() + + # Replace instance method + setattr(sess, "close", tracked_close) + return sess + + # Patch the symbol used in client.py to our factory + monkeypatch.setattr("fmd_api.client.aiohttp.ClientSession", factory) + + # Mock the first auth call to fail (salt 401) + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/salt", status=401) + with pytest.raises(FmdApiException): + await FmdClient.create("https://fmd.example.com", "id", "pw") + + # A session would have been created during the request; ensure it was closed + assert closed["count"] >= 1 + + +@pytest.mark.asyncio +async def test_create_with_insecure_ssl_configures_connector(monkeypatch): + """Using create() with ssl=False should not error and should configure connector accordingly.""" + + async def fake_authenticate(self, fmd_id, password, session_duration): + # Minimal stub to avoid network + self._fmd_id = fmd_id + self._password = password + self.access_token = "token" + + monkeypatch.setattr(FmdClient, "authenticate", fake_authenticate) + + client = await FmdClient.create("https://fmd.example.com", "id", "pw", ssl=False) + try: + # Ensure session is created and connector ssl=False + await client._ensure_session() + import aiohttp + + assert isinstance(client._session.connector, aiohttp.TCPConnector) + assert getattr(client._session.connector, "_ssl", None) is False + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_send_command_reauth(monkeypatch): + client = FmdClient("https://fmd.example.com") + # create a dummy private key with sign() + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + client._fmd_id = "id" + client._password = "pw" + client.access_token = "old-token" + + with aioresponses() as m: + # First POST returns 401 -> client should re-authenticate + m.post("https://fmd.example.com/api/v1/command", status=401) + # When authenticate is called during reauth, stub the internal calls: + + async def fake_authenticate(fmd_id, password, session_duration): + client.access_token = "new-token" + + monkeypatch.setattr(client, "authenticate", fake_authenticate) + # Second attempt should now succeed + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + try: + res = await client.send_command("ring") + assert res is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_export_data_zip_stream(monkeypatch, tmp_path): + """Test export_data_zip creates a ZIP file with locations and pictures (client-side).""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + client._fmd_id = "test-device" + + # Create a dummy private key for decryption + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + # Create fake encrypted location blob + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + iv = b"\x01" * 12 + plaintext = b'{"lat":1.0,"lon":2.0,"date":1600000000000}' + ciphertext = aesgcm.encrypt(iv, plaintext, None) + session_key_packet = b"\xaa" * 384 + blob = session_key_packet + iv + ciphertext + blob_b64 = base64.b64encode(blob).decode("utf-8").rstrip("=") + + with aioresponses() as m: + # Mock location API calls + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64}) + # Mock pictures API call + m.put("https://fmd.example.com/api/v1/pictures", payload={"Data": []}) + + out_file = tmp_path / "export.zip" + try: + result = await client.export_data_zip(str(out_file), include_pictures=True) + assert result == str(out_file) + assert out_file.exists() + + # Verify ZIP contains expected files + import zipfile + + with zipfile.ZipFile(out_file, "r") as zipf: + names = zipf.namelist() + assert "info.json" in names + assert "locations.json" in names + # Verify encrypted files are NOT included + assert "locations_encrypted.json" not in names + assert "pictures_encrypted.json" not in names + + # Check info.json has correct structure + info = json.loads(zipf.read("info.json")) + assert info["fmd_id"] == "test-device" + assert info["location_count"] == 1 + assert info["version"] == "2.0" + assert "pictures_extracted" in info + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_take_picture_validation(): + """Test take_picture validates camera parameter.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + with aioresponses() as m: + # Valid cameras should work + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + try: + assert await client.take_picture("front") is True + assert await client.take_picture("back") is True + finally: + await client.close() + + # Invalid camera should raise ValueError + client2 = FmdClient("https://fmd.example.com") + client2.access_token = "token" + client2.private_key = DummySigner() + try: + with pytest.raises(ValueError, match="Invalid camera.*Must be 'front' or 'back'"): + await client2.take_picture("rear") + finally: + await client2.close() + + +@pytest.mark.asyncio +async def test_set_ringer_mode_validation(): + """Test set_ringer_mode validates mode parameter.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + with aioresponses() as m: + # Valid modes should work + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + try: + assert await client.set_ringer_mode("normal") is True + assert await client.set_ringer_mode("vibrate") is True + assert await client.set_ringer_mode("silent") is True + finally: + await client.close() + + # Invalid mode should raise ValueError + client2 = FmdClient("https://fmd.example.com") + client2.access_token = "token" + client2.private_key = DummySigner() + try: + with pytest.raises(ValueError, match="Invalid ringer mode.*Must be"): + await client2.set_ringer_mode("loud") + finally: + await client2.close() + + +@pytest.mark.asyncio +async def test_request_location_providers(): + """Test request_location with different providers.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + with aioresponses() as m: + # Mock all provider requests + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + try: + assert await client.request_location("all") is True + assert await client.request_location("gps") is True + assert await client.request_location("cell") is True + assert await client.request_location("last") is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_set_bluetooth_and_dnd(): + """Test set_bluetooth and set_do_not_disturb commands.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + with aioresponses() as m: + # Mock commands + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + try: + assert await client.set_bluetooth(True) is True + assert await client.set_bluetooth(False) is True + assert await client.set_do_not_disturb(True) is True + assert await client.set_do_not_disturb(False) is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_get_device_stats(): + """Test get_device_stats sends stats command.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + with aioresponses() as m: + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + try: + assert await client.get_device_stats() is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_decrypt_data_blob_too_small(): + """Test decrypt_data_blob raises FmdApiException for small blobs.""" + from fmd_api.exceptions import FmdApiException + + client = FmdClient("https://fmd.example.com") + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + # Blob must be at least RSA_KEY_SIZE_BYTES (384) + AES_GCM_IV_SIZE_BYTES (12) = 396 bytes + too_small = base64.b64encode(b"x" * 100).decode("utf-8") + + with pytest.raises(FmdApiException, match="Blob too small for decryption"): + client.decrypt_data_blob(too_small) + + +@pytest.mark.asyncio +async def test_get_pictures_direct(): + """Test get_pictures endpoint directly.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + with aioresponses() as m: + # Mock pictures endpoint returning list of blobs + m.put("https://fmd.example.com/api/v1/pictures", payload=["blob1", "blob2", "blob3"]) + try: + pics = await client.get_pictures(num_to_get=2) + assert len(pics) == 2 + # Should get the 2 most recent (last 2 in reverse) + assert pics == ["blob3", "blob2"] + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_http_error_handling(): + """Test client handles various HTTP errors.""" + from fmd_api.exceptions import FmdApiException + + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + # Test 404 + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/locationDataSize", status=404) + try: + with pytest.raises(FmdApiException): + await client.get_locations() + finally: + await client.close() + + # Test 500 + client2 = FmdClient("https://fmd.example.com") + client2.access_token = "token" + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/locationDataSize", status=500) + try: + with pytest.raises(FmdApiException): + await client2.get_locations() + finally: + await client2.close() + + +@pytest.mark.asyncio +async def test_empty_location_response(): + """Test handling of empty location data.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + with aioresponses() as m: + # Server reports 0 locations + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "0"}) + try: + locs = await client.get_locations() + assert locs == [] + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_get_all_locations(): + """Test get_locations with num_to_get=-1 fetches all locations.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + # Create 3 location blobs + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + + blobs = [] + for i in range(3): + iv = bytes([i + 1] * 12) + plaintext = json.dumps({"lat": float(i), "lon": float(i * 10), "date": 1600000000000, "bat": 80}).encode( + "utf-8" + ) + ciphertext = aesgcm.encrypt(iv, plaintext, None) + blob = b"\xaa" * 384 + iv + ciphertext + blobs.append(base64.b64encode(blob).decode("utf-8").rstrip("=")) + + await client._ensure_session() + + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "3"}) + # When fetching all, indices are 0, 1, 2 + for i, blob_b64 in enumerate(blobs): + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64}) + + try: + locs = await client.get_locations(num_to_get=-1) + assert len(locs) == 3 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_skip_empty_locations(): + """Test that skip_empty skips over empty blobs to find valid ones.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + iv = b"\x05" * 12 + plaintext = b'{"lat":5.0,"lon":10.0,"date":1600000000000,"bat":90}' + ciphertext = aesgcm.encrypt(iv, plaintext, None) + blob = b"\xaa" * 384 + iv + ciphertext + blob_b64 = base64.b64encode(blob).decode("utf-8").rstrip("=") + + await client._ensure_session() + + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "3"}) + # Index 2 (most recent): empty + m.put("https://fmd.example.com/api/v1/location", payload={"Data": ""}) + # Index 1: empty + m.put("https://fmd.example.com/api/v1/location", payload={"Data": ""}) + # Index 0: valid + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64}) + + try: + locs = await client.get_locations(num_to_get=1, skip_empty=True) + assert len(locs) == 1 + assert locs[0] == blob_b64 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_picture_endpoint_error(): + """Test error handling when pictures endpoint fails.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + await client._ensure_session() + + with aioresponses() as m: + # pictures endpoint returns 500 error + m.put("https://fmd.example.com/api/v1/pictures", status=500) + + try: + # Should return empty list on error (client logs warning) + pictures = await client.get_pictures() + assert pictures == [] + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_multiple_commands_sequence(): + """Test sending multiple commands in sequence.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + + with aioresponses() as m: + # Mock multiple command requests + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + + try: + result1 = await client.send_command("ring") + assert result1 is True + + result2 = await client.send_command("lock") + assert result2 is True + + result3 = await client.send_command("locate") + assert result3 is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_get_pictures_pagination(): + """Test get_pictures with num_to_get limit.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + await client._ensure_session() + + with aioresponses() as m: + # Mock pictures response with multiple pictures (already in order) + mock_pictures = [ + {"id": 2, "date": 1600000002000}, + {"id": 1, "date": 1600000001000}, + {"id": 0, "date": 1600000000000}, + ] + m.put("https://fmd.example.com/api/v1/pictures", payload={"Data": mock_pictures}) + + try: + pictures = await client.get_pictures(num_to_get=2) + assert len(pictures) == 2 + # Should get the 2 most recent (last 2, reversed) + # [-2:][::-1] on [2,1,0] gives [1,0] then reverses to [0,1] + assert pictures[0]["id"] == 0 + assert pictures[1]["id"] == 1 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_authenticate_error_handling(): + """Test authenticate with invalid credentials.""" + client = FmdClient("https://fmd.example.com") + + await client._ensure_session() + + with aioresponses() as m: + # Mock authentication endpoints - first call is to /api/v1/salt + m.put("https://fmd.example.com/api/v1/salt", status=401) + + try: + from fmd_api.exceptions import FmdApiException + + with pytest.raises(FmdApiException, match="API request failed for /api/v1/salt"): + await client.authenticate("bad_id", "bad_password", session_duration=3600) + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_get_locations_with_skip_empty_false(): + """Test get_locations with skip_empty=False fetches all indices.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + await client._ensure_session() + + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "2"}) + # Both empty and valid blobs + m.put("https://fmd.example.com/api/v1/location", payload={"Data": ""}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": ""}) + + try: + # With skip_empty=False, should return empty blobs too + locs = await client.get_locations(num_to_get=2, skip_empty=False) + # Both are empty strings, so should get empty list since empty strings are filtered + assert len(locs) == 0 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_send_command_failure(): + """Test send_command when server returns non-200 status.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + + with aioresponses() as m: + m.post("https://fmd.example.com/api/v1/command", status=500, body="Server Error") + + try: + from fmd_api.exceptions import FmdApiException + + with pytest.raises(FmdApiException, match="Failed to send command"): + await client.send_command("ring") + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_decrypt_blob_invalid_format(): + """Test decrypt_data_blob with malformed blob.""" + client = FmdClient("https://fmd.example.com") + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + # Blob too short to contain IV and ciphertext + short_blob = base64.b64encode(b"x" * 10).decode("utf-8") + + from fmd_api.exceptions import FmdApiException + + with pytest.raises(FmdApiException, match="Blob too small"): + client.decrypt_data_blob(short_blob) + + +@pytest.mark.asyncio +async def test_export_data_404(): + """Test export_data_zip when API calls fail (e.g., no locations available).""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + client._fmd_id = "test" + + await client._ensure_session() + + with aioresponses() as m: + # Mock API to return error when fetching location size + m.put("https://fmd.example.com/api/v1/locationDataSize", status=500) + + try: + from fmd_api.exceptions import FmdApiException + import tempfile + + with tempfile.NamedTemporaryFile(delete=False) as tmp: + with pytest.raises(FmdApiException, match="Failed to export data"): + await client.export_data_zip(tmp.name) + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_get_pictures_empty_response(): + """Test get_pictures when server returns empty list.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + await client._ensure_session() + + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/pictures", payload={"Data": []}) + + try: + pictures = await client.get_pictures() + assert pictures == [] + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_request_location_unknown_provider(): + """Test request_location with unknown provider (falls back to 'locate').""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + + with aioresponses() as m: + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + + try: + # Unknown provider falls back to generic "locate" command + result = await client.request_location(provider="unknown") + assert result is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_close_without_session(): + """Test close when no session exists.""" + client = FmdClient("https://fmd.example.com") + # Should not raise error + await client.close() + assert client._session is None + + +@pytest.mark.asyncio +async def test_multiple_close_calls(): + """Test calling close multiple times.""" + client = FmdClient("https://fmd.example.com") + await client._ensure_session() + + # First close + await client.close() + assert client._session is None + + # Second close should not raise error + await client.close() + assert client._session is None + + +@pytest.mark.asyncio +async def test_get_locations_max_attempts(): + """Test get_locations respects max_attempts parameter.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + await client._ensure_session() + + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "100"}) + # Only set up 3 mocks even though max_attempts could be higher + for i in range(3): + m.put("https://fmd.example.com/api/v1/location", payload={"Data": ""}) + + try: + # Request 1 location from 100 available, with max_attempts=3 + locs = await client.get_locations(num_to_get=1, skip_empty=True, max_attempts=3) + # All 3 are empty, so should get empty list + assert len(locs) == 0 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_set_ringer_mode_edge_cases(): + """Test set_ringer_mode with all valid modes.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + + with aioresponses() as m: + # Mock for each valid mode + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + + try: + result1 = await client.set_ringer_mode("normal") + assert result1 is True + + result2 = await client.set_ringer_mode("vibrate") + assert result2 is True + + result3 = await client.set_ringer_mode("silent") + assert result3 is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_timeout_configuration(): + """Test that timeout can be configured at client level and per-request.""" + # Test 1: Default timeout is 30 seconds + client1 = FmdClient("https://fmd.example.com") + assert client1.timeout == 30.0 + + # Test 2: Custom timeout via constructor + client2 = FmdClient("https://fmd.example.com", timeout=60.0) + assert client2.timeout == 60.0 + + # Test 3: Timeout via create() factory method + with aioresponses() as m: + # Mock authentication flow + m.put("https://fmd.example.com/api/v1/salt", payload={"Data": base64.b64encode(b"x" * 16).decode().rstrip("=")}) + m.put("https://fmd.example.com/api/v1/requestAccess", payload={"Data": "fake-token"}) + m.put("https://fmd.example.com/api/v1/key", payload={"Data": "fake-key-blob"}) + + # Since we can't easily complete auth without real crypto, just test constructor accepts timeout + client3 = FmdClient("https://fmd.example.com", timeout=45.0) + assert client3.timeout == 45.0 + + # Test 4: Verify timeout is passed to aiohttp (integration test would be needed for full validation) + # For now, just confirm the attribute is stored correctly + client4 = FmdClient("https://fmd.example.com", cache_ttl=60, timeout=120.0) + assert client4.timeout == 120.0 + assert client4.cache_ttl == 60 + + await client1.close() + await client2.close() + await client3.close() + await client4.close() + + +@pytest.mark.asyncio +async def test_async_context_manager_direct(): + """FmdClient supports async with and auto-closes session.""" + client = FmdClient("https://fmd.example.com") + # Create session inside context and ensure it closes after + async with client as c: + assert c is client + await c._ensure_session() + assert c._session is not None and not c._session.closed + # After context exit, session should be closed and cleared + assert client._session is None + + +@pytest.mark.asyncio +async def test_async_context_manager_with_create(monkeypatch): + """Using async with await FmdClient.create(...) should auto-close session.""" + + async def fake_authenticate(self, fmd_id, password, session_duration): + # Minimal stub: set access_token without network + self._fmd_id = fmd_id + self._password = password + self.access_token = "token" + + monkeypatch.setattr(FmdClient, "authenticate", fake_authenticate) + + client = await FmdClient.create("https://fmd.example.com", "id", "pw") + async with client as c: + await c._ensure_session() + assert c._session is not None and not c._session.closed + assert client._session is None + + +@pytest.mark.asyncio +async def test_rate_limit_retry_with_retry_after(monkeypatch): + """Ensure 429 triggers sleep using Retry-After and then succeeds.""" + client = FmdClient("https://fmd.example.com", max_retries=2) + client.access_token = "token" + + slept = {"calls": []} + + async def fake_sleep(seconds): + slept["calls"].append(seconds) + return None + + monkeypatch.setattr("asyncio.sleep", fake_sleep) + + await client._ensure_session() + with aioresponses() as m: + # First call: 429 with Retry-After: 1, then success + m.put( + "https://fmd.example.com/api/v1/locationDataSize", + status=429, + headers={"Retry-After": "1"}, + ) + m.put( + "https://fmd.example.com/api/v1/locationDataSize", + payload={"Data": "0"}, + ) + + try: + locs = await client.get_locations() + assert locs == [] + # We should have slept once for ~1 second + assert len(slept["calls"]) == 1 + # Allow a small tolerance due to float conversion + assert abs(slept["calls"][0] - 1.0) < 0.01 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_server_error_retry_then_success(monkeypatch): + """Ensure 500 triggers exponential backoff retry and then success.""" + client = FmdClient("https://fmd.example.com", max_retries=2, backoff_base=0.1, jitter=False) + client.access_token = "token" + + slept = {"calls": []} + + async def fake_sleep(seconds): + slept["calls"].append(seconds) + return None + + monkeypatch.setattr("asyncio.sleep", fake_sleep) + + await client._ensure_session() + with aioresponses() as m: + # First attempt 500, second attempt 200 with Data=0 + m.put("https://fmd.example.com/api/v1/locationDataSize", status=500) + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "0"}) + + try: + locs = await client.get_locations() + assert locs == [] + # One backoff sleep, base=0.1, attempt0 -> 0.1 seconds when no jitter + assert slept["calls"] == [0.1] + finally: + await client.close() diff --git a/tests/unit/test_device.py b/tests/unit/test_device.py new file mode 100644 index 0000000..4a73c29 --- /dev/null +++ b/tests/unit/test_device.py @@ -0,0 +1,753 @@ +import base64 +import json + +import pytest +from aioresponses import aioresponses + +from fmd_api.client import FmdClient +from fmd_api.device import Device + + +@pytest.mark.asyncio +async def test_device_refresh_and_get_location(monkeypatch): + client = FmdClient("https://fmd.example.com") + # Dummy private_key decrypt path (reuse approach from client tests) + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + # Create a simple AES-GCM encrypted location blob (same scheme as client test) + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + iv = b"\x02" * 12 + plaintext = b'{"lat":10.0,"lon":20.0,"date":1600000000000,"bat":80}' + ciphertext = aesgcm.encrypt(iv, plaintext, None) + blob = b"\xaa" * 384 + iv + ciphertext + blob_b64 = base64.b64encode(blob).decode("utf-8").rstrip("=") + + client.access_token = "token" + device = Device(client, "alice") + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64}) + try: + await device.refresh() + loc = await device.get_location() + assert loc is not None + assert abs(loc.lat - 10.0) < 1e-6 + assert abs(loc.lon - 20.0) < 1e-6 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_fetch_and_download_picture(monkeypatch): + client = FmdClient("https://fmd.example.com") + # Provide dummy private key that decrypts session packet into all-zero key + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + # Prepare an "encrypted blob" that after decrypt yields a base64 image string. + # For test simplicity, we'll make decrypted payload the base64 of b'PNGDATA' + inner_image = base64.b64encode(b"PNGDATA").decode("utf-8") + # Encrypt inner_image using AESGCM with zero key + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + iv = b"\x03" * 12 + ciphertext = aesgcm.encrypt(iv, inner_image.encode("utf-8"), None) + blob = b"\xaa" * 384 + iv + ciphertext + blob_b64 = base64.b64encode(blob).decode("utf-8").rstrip("=") + + with aioresponses() as m: + # get_pictures endpoint returns a JSON list; emulate simple list containing our blob + m.put("https://fmd.example.com/api/v1/pictures", payload=[blob_b64]) + client.access_token = "token" + device = Device(client, "alice") + try: + pics = await device.fetch_pictures() + assert len(pics) == 1 + # download the picture and verify we got PNGDATA bytes + photo = await device.download_photo(pics[0]) + assert photo.data == b"PNGDATA" + assert photo.mime_type.startswith("image/") + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_command_wrappers(): + """Test Device command wrapper methods.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + device = Device(client, "test-device") + + with aioresponses() as m: + # Mock all command endpoints + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + try: + assert await device.play_sound() is True + assert await device.take_front_photo() is True + assert await device.take_rear_photo() is True + assert await device.lock() is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_wipe_requires_confirm(): + """Test Device.wipe requires confirm=True.""" + from fmd_api.exceptions import OperationError + + client = FmdClient("https://fmd.example.com") + device = Device(client, "test-device") + + # Should raise without confirm + with pytest.raises(OperationError, match="wipe.*requires confirm=True"): + await device.wipe() + + # Should work with confirm + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + with aioresponses() as m: + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + try: + assert await device.wipe(confirm=True) is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_empty_location(): + """Test Device handles empty location gracefully.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + client.access_token = "token" + device = Device(client, "test-device") + + with aioresponses() as m: + # Server reports 0 locations + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "0"}) + try: + loc = await device.get_location() + assert loc is None + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_get_history(): + """Test Device.get_history async iterator.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + # Create two location blobs + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + + blobs = [] + for i, (lat, lon) in enumerate([(10.0, 20.0), (11.0, 21.0)]): + iv = bytes([i + 1] * 12) + plaintext = json.dumps({"lat": lat, "lon": lon, "date": 1600000000000 + i * 1000, "bat": 80}).encode("utf-8") + ciphertext = aesgcm.encrypt(iv, plaintext, None) + blob = b"\xaa" * 384 + iv + ciphertext + blobs.append(base64.b64encode(blob).decode("utf-8").rstrip("=")) + + client.access_token = "token" + device = Device(client, "test-device") + + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "2"}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blobs[0]}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blobs[1]}) + try: + locs = [] + async for loc in device.get_history(limit=2): + locs.append(loc) + + assert len(locs) == 2 + assert abs(locs[0].lat - 10.0) < 1e-6 + assert abs(locs[1].lat - 11.0) < 1e-6 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_force_refresh(): + """Test Device force refresh bypasses cache.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + iv = b"\x05" * 12 + plaintext = b'{"lat":15.0,"lon":25.0,"date":1600000000000,"bat":90}' + ciphertext = aesgcm.encrypt(iv, plaintext, None) + blob = b"\xaa" * 384 + iv + ciphertext + blob_b64 = base64.b64encode(blob).decode("utf-8").rstrip("=") + + client.access_token = "token" + device = Device(client, "test-device") + + with aioresponses() as m: + # First refresh + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64}) + # Force refresh (should hit endpoints again) + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64}) + try: + await device.refresh() + loc1 = await device.get_location() + + # Without force, should return cached + loc2 = await device.get_location() + assert loc1 is loc2 # Same object + + # With force, should fetch again + loc3 = await device.get_location(force=True) + assert abs(loc3.lat - 15.0) < 1e-6 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_cached_location_property(): + """Test Device.cached_location property access.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + iv = b"\x06" * 12 + plaintext = b'{"lat":20.0,"lon":30.0,"date":1600000000000,"bat":75}' + ciphertext = aesgcm.encrypt(iv, plaintext, None) + blob = b"\xaa" * 384 + iv + ciphertext + blob_b64 = base64.b64encode(blob).decode("utf-8").rstrip("=") + + client.access_token = "token" + device = Device(client, "test-device") + + # Initially None + assert device.cached_location is None + + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64}) + try: + await device.refresh() + # Now should have cached location + assert device.cached_location is not None + assert abs(device.cached_location.lat - 20.0) < 1e-6 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_refresh_without_force(): + """Test Device.refresh with force=False doesn't re-fetch if cached.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + iv = b"\x07" * 12 + plaintext = b'{"lat":25.0,"lon":35.0,"date":1600000000000,"bat":85}' + ciphertext = aesgcm.encrypt(iv, plaintext, None) + blob = b"\xaa" * 384 + iv + ciphertext + blob_b64 = base64.b64encode(blob).decode("utf-8").rstrip("=") + + client.access_token = "token" + device = Device(client, "test-device") + + with aioresponses() as m: + # Only one set of mocks + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64}) + try: + await device.refresh() + loc1 = device.cached_location + + # Second refresh without force should not make HTTP calls (mocks would fail if it did) + await device.refresh(force=False) + loc2 = device.cached_location + + assert loc1 is loc2 # Same cached object + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_picture_commands(): + """Test Device picture-related command shortcuts.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + device = Device(client, "test-device") + + with aioresponses() as m: + # take_front_photo + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + # take_rear_photo + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + + try: + result1 = await device.take_front_photo() + assert result1 is True + + result2 = await device.take_rear_photo() + assert result2 is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_lock_with_message(): + """Test Device.lock with custom message.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + device = Device(client, "test-device") + + with aioresponses() as m: + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + + try: + result = await device.lock("Please return this device") + assert result is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_multiple_history_calls(): + """Test Device.get_history can be called multiple times.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + + blobs = [] + for i in range(2): + iv = bytes([i + 10] * 12) + plaintext = json.dumps( + {"lat": float(30 + i), "lon": float(40 + i), "date": 1600000000000 + i * 1000, "bat": 80} + ).encode("utf-8") + ciphertext = aesgcm.encrypt(iv, plaintext, None) + blob = b"\xaa" * 384 + iv + ciphertext + blobs.append(base64.b64encode(blob).decode("utf-8").rstrip("=")) + + client.access_token = "token" + device = Device(client, "test-device") + + with aioresponses() as m: + # First call to get_history + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "2"}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blobs[0]}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blobs[1]}) + + # Second call to get_history + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "2"}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blobs[0]}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blobs[1]}) + + try: + # First iteration + locs1 = [] + async for loc in device.get_history(limit=2): + locs1.append(loc) + assert len(locs1) == 2 + + # Second iteration (should work independently) + locs2 = [] + async for loc in device.get_history(limit=2): + locs2.append(loc) + assert len(locs2) == 2 + + # Both should have same data (different Location objects) + assert abs(locs1[0].lat - locs2[0].lat) < 1e-6 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_name_property(): + """Test Device.name property from raw data.""" + client = FmdClient("https://fmd.example.com") + device = Device(client, "my-phone-id", raw={"name": "My Phone"}) + + assert device.name == "My Phone" + assert device.id == "my-phone-id" + await client.close() + + +@pytest.mark.asyncio +async def test_device_wipe_with_confirm(): + """Test Device.wipe when confirm=True actually sends command.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + device = Device(client, "test-device") + + with aioresponses() as m: + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + + try: + result = await device.wipe(confirm=True) + assert result is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_ringer_via_client(): + """Test Device can use client's set_ringer_mode.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + device = Device(client, "test-device") + + with aioresponses() as m: + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + + try: + # Device doesn't have set_ringer_mode, use client directly + result = await device.client.set_ringer_mode("vibrate") + assert result is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_bluetooth_via_client(): + """Test Device can use client's set_bluetooth.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + device = Device(client, "test-device") + + with aioresponses() as m: + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + + try: + # Device doesn't have set_bluetooth, use client directly + result1 = await device.client.set_bluetooth(True) + assert result1 is True + + result2 = await device.client.set_bluetooth(False) + assert result2 is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_dnd_via_client(): + """Test Device can use client's set_do_not_disturb.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + device = Device(client, "test-device") + + with aioresponses() as m: + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + + try: + # Device doesn't have set_do_not_disturb, use client directly + result1 = await device.client.set_do_not_disturb(True) + assert result1 is True + + result2 = await device.client.set_do_not_disturb(False) + assert result2 is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_get_history_with_all_locations(): + """Test Device.get_history with limit=-1 fetches all.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + + blobs = [] + for i in range(3): + iv = bytes([i + 20] * 12) + plaintext = json.dumps( + {"lat": float(40 + i), "lon": float(50 + i), "date": 1600000000000 + i * 1000, "bat": 85} + ).encode("utf-8") + ciphertext = aesgcm.encrypt(iv, plaintext, None) + blob = b"\xaa" * 384 + iv + ciphertext + blobs.append(base64.b64encode(blob).decode("utf-8").rstrip("=")) + + client.access_token = "token" + device = Device(client, "test-device") + + with aioresponses() as m: + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "3"}) + for blob_b64 in blobs: + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob_b64}) + + try: + locs = [] + async for loc in device.get_history(limit=-1): + locs.append(loc) + + assert len(locs) == 3 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_fetch_pictures(): + """Test Device.fetch_pictures method.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + await client._ensure_session() + device = Device(client, "test-device") + + with aioresponses() as m: + mock_pictures = [{"id": 0, "date": 1600000000000}] + m.put("https://fmd.example.com/api/v1/pictures", payload={"Data": mock_pictures}) + + try: + pictures = await device.fetch_pictures(num_to_get=1) + assert len(pictures) == 1 + assert pictures[0]["id"] == 0 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_request_location_via_client(): + """Test Device can use client's request_location.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + device = Device(client, "test-device") + + with aioresponses() as m: + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + + try: + # Device doesn't have request_location, use client directly + result = await device.client.request_location(provider="gps") + assert result is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_get_stats_via_client(): + """Test Device can use client's get_device_stats.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummySigner: + def sign(self, message_bytes, pad, algo): + return b"\xab" * 64 + + client.private_key = DummySigner() + + await client._ensure_session() + device = Device(client, "test-device") + + with aioresponses() as m: + m.post("https://fmd.example.com/api/v1/command", status=200, body="OK") + + try: + # Device doesn't have get_stats, use client's get_device_stats + result = await device.client.get_device_stats() + assert result is True + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_device_refresh_updates_cached_location(): + """Test that refresh() updates the cached location.""" + client = FmdClient("https://fmd.example.com") + client.access_token = "token" + + class DummyKey: + def decrypt(self, packet, padding_obj): + return b"\x00" * 32 + + client.private_key = DummyKey() + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + session_key = b"\x00" * 32 + aesgcm = AESGCM(session_key) + + # Create two different blobs + iv1 = b"\x30" * 12 + plaintext1 = b'{"lat":50.0,"lon":60.0,"date":1600000000000,"bat":90}' + ciphertext1 = aesgcm.encrypt(iv1, plaintext1, None) + blob1 = b"\xaa" * 384 + iv1 + ciphertext1 + blob1_b64 = base64.b64encode(blob1).decode("utf-8").rstrip("=") + + iv2 = b"\x31" * 12 + plaintext2 = b'{"lat":55.0,"lon":65.0,"date":1600000001000,"bat":85}' + ciphertext2 = aesgcm.encrypt(iv2, plaintext2, None) + blob2 = b"\xaa" * 384 + iv2 + ciphertext2 + blob2_b64 = base64.b64encode(blob2).decode("utf-8").rstrip("=") + + client.access_token = "token" + device = Device(client, "test-device") + + with aioresponses() as m: + # First refresh + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob1_b64}) + + # Second refresh with force=True + m.put("https://fmd.example.com/api/v1/locationDataSize", payload={"Data": "1"}) + m.put("https://fmd.example.com/api/v1/location", payload={"Data": blob2_b64}) + + try: + await device.refresh() + loc1 = device.cached_location + assert abs(loc1.lat - 50.0) < 1e-6 + + # Force refresh should get new data + await device.refresh(force=True) + loc2 = device.cached_location + assert abs(loc2.lat - 55.0) < 1e-6 + finally: + await client.close() diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..aa53150 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,4 @@ +# Test utilities package +from .read_credentials import read_credentials + +__all__ = ["read_credentials"] diff --git a/tests/utils/credentials.txt.example b/tests/utils/credentials.txt.example new file mode 100644 index 0000000..f9fdf13 --- /dev/null +++ b/tests/utils/credentials.txt.example @@ -0,0 +1,16 @@ +# Credentials file for fmd_api test scripts. +# Save this file as 'credentials.txt' (same directory) and DO NOT commit real credentials. +# +# Format: one key=value per line. Required keys: +# BASE_URL - base URL of the FMD server (e.g. https://fmd.example.com) +# FMD_ID - your fmd id (account/device id) +# PASSWORD - your account password +# +# Optional: +# DEVICE_ID - device id if different from FMD_ID (used by Device tests) +# +# Example: +BASE_URL=https://fmd.example.com +FMD_ID=alice +PASSWORD=secret +#DEVICE_ID=alice-device diff --git a/tests/utils/read_credentials.py b/tests/utils/read_credentials.py new file mode 100644 index 0000000..910fcf4 --- /dev/null +++ b/tests/utils/read_credentials.py @@ -0,0 +1,34 @@ +""" +Utility: read credentials from credentials.txt (KEY=VALUE lines) + +Place credentials.txt in tests/utils/ or set environment variables. +""" + +from pathlib import Path +from typing import Optional, Union, Dict +import os + + +def read_credentials(path: Optional[Union[str, Path]] = None) -> Dict[str, str]: + """Return dict of credentials from the given file. Falls back to env vars if not present.""" + creds = {} + if path is None: + # Default to tests/utils/credentials.txt + path = Path(__file__).parent / "credentials.txt" + p = Path(path) + if p.exists(): + for ln in p.read_text().splitlines(): + ln = ln.strip() + if not ln or ln.startswith("#"): + continue + if "=" in ln: + k, v = ln.split("=", 1) + creds[k.strip()] = v.strip() + # Fallback to environment for keys not provided. + # Only accept non-empty values to avoid silently using empty strings. + for k in ("BASE_URL", "FMD_ID", "PASSWORD", "DEVICE_ID"): + if k not in creds: + val = os.getenv(k) + if val: # skip None and empty strings + creds[k] = val + return creds