Skip to content

Commit b272d31

Browse files
committed
Initial draft of rewrites
1 parent af1801a commit b272d31

34 files changed

Lines changed: 1146 additions & 2775 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ __pycache__/
33
*.pyc
44
*.pyo
55
*.pyd
6+
*.zip
67

78
# C extensions
89
*.so

MIGRATE_FROM_V1.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
```markdown
2+
# Migrating from fmd_api v1 (module style) to v2 (FmdClient + Device)
3+
4+
This short guide shows common v1 usages (from fmd_api.py) and how to perform the
5+
equivalent actions using the new FmdClient and Device classes.
6+
7+
Authenticate
8+
v1:
9+
```python
10+
api = await FmdApi.create("https://fmd.example.com", "alice", "secret")

PROPOSAL.md

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
# Proposal: fmd_api v2 — Device-centric async interface
2+
3+
Status: Draft
4+
Author: devinslick (proposal by Copilot Space)
5+
Date: 2025-11-01
6+
7+
## Goals
8+
9+
- Replace the current functional-style API with a small object model that exposes a Device class representing a single tracked device.
10+
- Keep all existing behavior and business logic of the current implementation, but convert operations to awaitable async methods to satisfy Home Assistant integration requirements.
11+
- Provide an idiomatic, easy-to-test, and extensible interface:
12+
- Device objects that encapsulate identifiers, state, and operations (refresh, play_sound, locate, take photos, etc).
13+
- A lightweight Client that handles authentication, session management, rate limiting, and discovery of devices.
14+
- 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.
15+
- Include unit-test and integration-test guidance and examples.
16+
17+
## Overview of the new design
18+
19+
Top-level components:
20+
- FmdClient: an async client that manages session, authentication tokens, request throttling, and device discovery.
21+
- 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).
22+
- Exceptions: typed exceptions for common error cases (AuthenticationError, DeviceNotFoundError, FmdApiError, RateLimitError).
23+
- Utilities: small helpers for caching, TTL-based per-device caches, retry/backoff, JSON parsing.
24+
25+
Rationale:
26+
- Home Assistant requires async-enabled integrations to avoid blocking the event loop. Converting to async lets this library integrate smoothly.
27+
- Representing a device as an object makes the API easier to reason about, test, and extend.
28+
- Centralized client manages authentication and rate-limiting across multiple Device instances.
29+
- Avoiding a built-in legacy shim keeps the code simple and encourages direct migration.
30+
31+
## Public API
32+
33+
Example usage:
34+
35+
```python
36+
from fmd_api import FmdClient
37+
38+
async def example():
39+
client = FmdClient(username="me@example.com", password="hunter2")
40+
await client.authenticate()
41+
42+
devices = await client.get_devices() # list[Device]
43+
device = devices[0]
44+
45+
# Get latest known location (from cache or backend)
46+
loc = await device.get_location()
47+
48+
# Force a refresh from backend
49+
await device.refresh(force=True)
50+
51+
# Trigger play sound
52+
await device.play_sound()
53+
54+
# Take front and rear photos
55+
front = await device.take_front_photo()
56+
rear = await device.take_rear_photo()
57+
58+
# Lock device with message
59+
await device.lock_device(message="Lost phone — call me")
60+
61+
# Wipe device (dangerous)
62+
# await device.wipe_device(confirm=True)
63+
64+
# Close client when finished
65+
await client.close()
66+
```
67+
68+
Core classes and signatures (proposal):
69+
70+
- FmdClient
71+
- __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)
72+
- async authenticate(self) -> None
73+
- async get_devices(self) -> list["Device"]
74+
- async get_device(self, device_id: str) -> "Device"
75+
- async close(self) -> None
76+
- properties:
77+
- auth_token (read-only)
78+
- is_authenticated: bool
79+
- cache_ttl: int
80+
81+
- Device
82+
- Attributes:
83+
- client: FmdClient (back-reference)
84+
- id: str
85+
- name: str
86+
- model: Optional[str]
87+
- battery: Optional[int]
88+
- is_online: Optional[bool]
89+
- last_seen: Optional[datetime]
90+
- cached_location: Optional[Location]
91+
- raw: dict
92+
- Methods (async):
93+
- async refresh(self, *, force: bool = False) -> None
94+
- Updates device state and location from backend. Honor per-device cache TTL when force=False.
95+
- async get_location(self, *, force: bool = False) -> Optional[Location]
96+
- Returns last known location (calls refresh if expired or force=True)
97+
- async play_sound(self, *, volume: Optional[int] = None) -> None
98+
- async take_front_photo(self) -> Optional[bytes]
99+
- Requests a front-facing photo; returns raw bytes of image if available.
100+
- async take_rear_photo(self) -> Optional[bytes]
101+
- Requests a rear-facing photo; returns raw bytes of image if available.
102+
- async lock_device(self, *, passcode: Optional[str] = None, message: Optional[str] = None) -> None
103+
- async wipe_device(self, *, confirm: bool = False) -> None
104+
- async set_label(self, label: str) -> None
105+
- to_dict(self) -> dict
106+
- __repr__/__str__ helper for debugging
107+
108+
- Location (dataclass)
109+
- lat: float
110+
- lon: float
111+
- accuracy_m: Optional[float]
112+
- timestamp: datetime
113+
- raw: dict
114+
115+
- PhotoResult (dataclass)
116+
- bytes: bytes
117+
- mime_type: str
118+
- timestamp: datetime
119+
- raw: dict
120+
121+
- Exceptions
122+
- FmdApiError(Exception)
123+
- AuthenticationError(FmdApiError)
124+
- DeviceNotFoundError(FmdApiError)
125+
- RateLimitError(FmdApiError)
126+
- OperationError(FmdApiError)
127+
128+
## Behavior details & compatibility with current logic
129+
130+
- 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.
131+
- 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.
132+
- Device.refresh() mirrors current "get devices" and "refresh device" flows: fetch the device status endpoint, parse location, battery, and update fields.
133+
- 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.
134+
- 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.
135+
- 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.
136+
- Retries: transient HTTP errors will be retried with an exponential backoff (configurable; default 3 retries).
137+
- Error handling: HTTP 401 triggers AuthenticationError; 404 for device endpoints raises DeviceNotFoundError; 429 triggers RateLimitError.
138+
139+
## Async design considerations
140+
141+
- Use aiohttp.ClientSession for requests. The session can be provided by a caller (for reuse) or created by the client.
142+
- All public methods are async and return without blocking the event loop.
143+
- Methods that call multiple network endpoints (for example, refresh that fetches multiple resources) should gather coroutines where parallelism is safe, using asyncio.gather.
144+
- 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).
145+
146+
## Migration notes (from current repo)
147+
148+
- We will NOT ship an in-code legacy compatibility adapter. Instead, include a short README (docs/MIGRATE_FROM_V1.md) with:
149+
- A mapping table of old function names to the new FmdClient/Device equivalents.
150+
- Short code snippets showing how to migrate common flows (list devices, refresh, ring, take photos).
151+
- Notes about switching to async and recommended patterns for calling from synchronous scripts (e.g., using asyncio.run or running inside an existing loop).
152+
- Example migration snippet:
153+
154+
Old v1 (sync):
155+
```python
156+
d = get_device("device-id")
157+
location = d["location"]
158+
ring_device("device-id")
159+
```
160+
161+
New v2 (async):
162+
```python
163+
client = FmdClient(username="u", password="p")
164+
await client.authenticate()
165+
device = await client.get_device("device-id")
166+
location = await device.get_location()
167+
await device.play_sound()
168+
```
169+
170+
## Testing
171+
172+
- Unit tests:
173+
- Mock aiohttp responses using aioresponses or pytest-aiohttp.
174+
- 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.
175+
- Integration tests:
176+
- Optionally include an integration test suite that can run against a staging backend or recorded responses (VCR-like fixture).
177+
- Linting:
178+
- Enforce mypy typing for public API and add tests that ensure Device methods are awaitable.
179+
180+
## Implementation plan (phased)
181+
182+
1. Agree on method names and shapes in this proposal.
183+
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.
184+
3. Port parsing logic from current code into async request handlers.
185+
4. Implement caching, rate limiting, and retries.
186+
5. Add docs/MIGRATE_FROM_V1.md migration guide and examples.
187+
6. Add Home Assistant integration notes and an example integration using DataUpdateCoordinator.
188+
7. Add unit/integration tests and CI (GitHub Actions).
189+
8. Release v2.0.0 with upgrade notes.
190+
191+
Estimated effort:
192+
- Core async client + Device, basic tests: 1–2 days
193+
- Full feature parity (all endpoints + play/lock/wipe/photos): 2–3 days
194+
- Tests + CI + HA docs: 1–2 days
195+
196+
## File layout proposal
197+
198+
- fmd_api/
199+
- __init__.py # exports FmdClient, Device, exceptions
200+
- client.py # FmdClient implementation
201+
- device.py # Device model & methods
202+
- types.py # dataclasses: Location, PhotoResult, DeviceInfo
203+
- exceptions.py # typed exceptions
204+
- rate_limiter.py # simple rate limiter utilities
205+
- cache.py # TTL cache helpers
206+
- helpers.py # utility functions, parsers
207+
- tests/
208+
- test_client.py
209+
- test_device.py
210+
- fixtures/
211+
- docs/
212+
- ha_integration.md
213+
- MIGRATE_FROM_V1.md # short migration guide from existing repo
214+
- examples/
215+
- async_example.py
216+
217+
## Home Assistant integration notes
218+
219+
- Use FmdClient in the integration's async_setup_entry() method.
220+
- Create a DataUpdateCoordinator per entry or a single coordinator that manages all devices:
221+
- Coordinator calls client.get_devices() periodically and updates entities.
222+
- Entities keep a reference to their Device object and read properties directly.
223+
- Expose device actions (play_sound, take_front_photo, take_rear_photo, lock, wipe) through Home Assistant services that call the corresponding async Device methods.
224+
225+
Example HA pattern:
226+
- On setup:
227+
- client = FmdClient(...)
228+
- await client.authenticate()
229+
- devices = await client.get_devices()
230+
- coordinator = DataUpdateCoordinator(..., update_method=client.get_devices)
231+
- create entities that hold Device references
232+
233+
## Example Device class (sketch)
234+
235+
```python
236+
class Device:
237+
def __init__(self, client: FmdClient, raw: dict):
238+
self.client = client
239+
self.id = raw["id"]
240+
self.name = raw.get("name")
241+
self.raw = raw
242+
self.cached_location: Optional[Location] = None
243+
self._last_refresh = datetime.min
244+
245+
async def refresh(self, *, force: bool = False) -> None:
246+
if not force and (utcnow() - self._last_refresh).total_seconds() < self.client.cache_ttl:
247+
return
248+
data = await self.client._request("GET", f"/devices/{self.id}")
249+
self._update_from_raw(data)
250+
self._last_refresh = utcnow()
251+
252+
async def get_location(self, *, force: bool = False) -> Optional[Location]:
253+
await self.refresh(force=force)
254+
return self.cached_location
255+
256+
async def play_sound(self, *, volume: Optional[int] = None) -> None:
257+
await self.client._request("POST", f"/devices/{self.id}/play_sound", json={"volume": volume} if volume else None)
258+
259+
async def take_front_photo(self) -> Optional[PhotoResult]:
260+
resp = await self.client._request("POST", f"/devices/{self.id}/take_photo", json={"camera": "front"})
261+
return parse_photo_result(resp)
262+
263+
async def take_rear_photo(self) -> Optional[PhotoResult]:
264+
resp = await self.client._request("POST", f"/devices/{self.id}/take_photo", json={"camera": "rear"})
265+
return parse_photo_result(resp)
266+
```
267+
268+
## Security considerations
269+
270+
- Store tokens securely; do not log secrets. Client will redact token values from debug logs.
271+
- Encourage the use of per-integration API keys if the backend supports them.
272+
- Provide options to scope rate limits and avoid accidental account lockouts via aggressive command usage.
273+
274+
## API docs & examples
275+
276+
- Add README sections showing:
277+
- Basic usage (authenticate, list devices, one-liners)
278+
- Example usage inside Home Assistant
279+
- Migration guide from v1 to v2 (docs/MIGRATE_FROM_V1.md)
280+
- How to run tests
281+
282+
## Closing
283+
284+
This proposal updates the earlier draft to:
285+
- Use the more concise FmdClient name (no Async prefix).
286+
- Include explicit methods for taking front and rear photos.
287+
- Drop an in-code legacy compatibility layer and instead provide a small migration README.
288+
289+
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.

PROPOSED_BRANCH_AND_STRUCTURE.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
```markdown
2+
Branch: feature/v2-device-client
3+
4+
This document proposes the initial branch and repository layout for fmd_api v2
5+
(FmdClient + Device). The branch name above is the suggested feature branch
6+
to create in the repository.
7+
8+
Goals for the branch:
9+
- Add package layout and initial skeleton modules that port the current
10+
fmd_api.py behavior into a FmdClient class and a Device class while
11+
preserving the decryption, auth, and command semantics.
12+
- Provide minimal, well-typed skeletons and README + migration doc to guide
13+
further work and make it easy to add tests incrementally.
14+
15+
Top-level layout (files to add in feature branch)
16+
- fmd_api/
17+
- __init__.py
18+
- client.py
19+
- device.py
20+
- types.py
21+
- exceptions.py
22+
- helpers.py
23+
- _version.py
24+
- tests/
25+
- __init__.py
26+
- test_client.py
27+
- test_device.py
28+
- docs/
29+
- MIGRATE_FROM_V1.md
30+
- ha_integration.md
31+
- examples/
32+
- async_example.py
33+
- PROPOSAL.md # updated proposal (keeps current proposal but with final details)
34+
- pyproject.toml (skeleton)
35+
- tox.ini / .github/workflows/ci.yml (placeholders)
36+
37+
Next steps after branch creation:
38+
1. Implement FmdClient.create() by porting fmd_api.FmdApi.create and its helper methods.
39+
2. Implement decrypt_data_blob unchanged and expose as FmdClient.decrypt_data_blob.
40+
3. Add Device wrappers (take_picture, request_location, get_locations -> get_history/get_location).
41+
4. Add tests that assert parity for authentication and decrypt_data_blob (using recorded values or mocks).
42+
5. Iterate on rate-limiter/cache and add streaming helpers for export_data_zip.
43+
44+
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?
45+
```

0 commit comments

Comments
 (0)