Skip to content

Commit 62f33e2

Browse files
committed
Create and use new Location.from_json method to allow for reusable JSON parsing
1 parent d0ada32 commit 62f33e2

File tree

3 files changed

+120
-33
lines changed

3 files changed

+120
-33
lines changed

fmd_api/device.py

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,7 @@ async def refresh(self, *, force: bool = False):
4444

4545
# decrypt and parse JSON
4646
decrypted = self.client.decrypt_data_blob(blobs[0])
47-
loc = json.loads(decrypted)
48-
# Build Location object with fields from README / fmd_api.py
49-
timestamp_ms = loc.get("date")
50-
ts = datetime.fromtimestamp(timestamp_ms / 1000.0, tz=timezone.utc) if timestamp_ms else None
51-
self.cached_location = Location(
52-
lat=loc["lat"],
53-
lon=loc["lon"],
54-
timestamp=ts,
55-
accuracy_m=loc.get("accuracy"),
56-
altitude_m=loc.get("altitude"),
57-
speed_m_s=loc.get("speed"),
58-
heading_deg=loc.get("heading"),
59-
battery_pct=loc.get("bat"),
60-
provider=loc.get("provider"),
61-
raw=loc,
62-
)
47+
self.cached_location = Location.from_json(decrypted.decode("utf-8"))
6348

6449
async def get_location(self, *, force: bool = False) -> Optional[Location]:
6550
if force or self.cached_location is None:
@@ -81,21 +66,7 @@ async def get_history(self, start=None, end=None, limit: int = -1) -> AsyncItera
8166
for b in blobs:
8267
try:
8368
decrypted = self.client.decrypt_data_blob(b)
84-
loc = json.loads(decrypted)
85-
timestamp_ms = loc.get("date")
86-
ts = datetime.fromtimestamp(timestamp_ms / 1000.0, tz=timezone.utc) if timestamp_ms else None
87-
yield Location(
88-
lat=loc["lat"],
89-
lon=loc["lon"],
90-
timestamp=ts,
91-
accuracy_m=loc.get("accuracy"),
92-
altitude_m=loc.get("altitude"),
93-
speed_m_s=loc.get("speed"),
94-
heading_deg=loc.get("heading"),
95-
battery_pct=loc.get("bat"),
96-
provider=loc.get("provider"),
97-
raw=loc,
98-
)
69+
yield Location.from_json(decrypted.decode("utf-8"))
9970
except Exception as e:
10071
# skip invalid blobs but log
10172
raise OperationError(f"Failed to decrypt/parse location blob: {e}") from e

fmd_api/models.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from dataclasses import dataclass
2-
from datetime import datetime
3-
from typing import Optional, Dict, Any
2+
from datetime import datetime, timezone
3+
from typing import Optional, Dict, Any, Union
4+
import json as _json
45

56

67
@dataclass
@@ -16,6 +17,51 @@ class Location:
1617
provider: Optional[str] = None
1718
raw: Optional[Dict[str, Any]] = None
1819

20+
@classmethod
21+
def from_json(cls, json: Union[str, Dict[str, Any]]) -> "Location":
22+
"""Construct a Location from a JSON dict or JSON string.
23+
24+
Expected fields (from server payloads):
25+
- lat (float)
26+
- lon (float)
27+
- date (int milliseconds since epoch)
28+
- Optional: accuracy, altitude, speed, heading, bat, provider
29+
"""
30+
# Accept either a JSON string or a dict
31+
if isinstance(json, str):
32+
try:
33+
data = _json.loads(json)
34+
except Exception as e:
35+
raise ValueError(f"Invalid JSON string for Location: {e}") from e
36+
elif isinstance(json, dict):
37+
data = json
38+
else:
39+
raise TypeError("Location.from_json expects a dict or JSON string")
40+
41+
if "lat" not in data or "lon" not in data:
42+
raise ValueError("Location JSON must include 'lat' and 'lon'")
43+
44+
# Convert date (ms since epoch) to aware datetime in UTC if present
45+
ts = None
46+
if data.get("date") is not None:
47+
try:
48+
ts = datetime.fromtimestamp(float(data["date"]) / 1000.0, tz=timezone.utc)
49+
except Exception as e:
50+
raise ValueError(f"Invalid 'date' field for Location: {e}") from e
51+
52+
return cls(
53+
lat=float(data["lat"]),
54+
lon=float(data["lon"]),
55+
timestamp=ts,
56+
accuracy_m=(float(data["accuracy"]) if data.get("accuracy") is not None else None),
57+
altitude_m=(float(data["altitude"]) if data.get("altitude") is not None else None),
58+
speed_m_s=(float(data["speed"]) if data.get("speed") is not None else None),
59+
heading_deg=(float(data["heading"]) if data.get("heading") is not None else None),
60+
battery_pct=(int(data["bat"]) if data.get("bat") is not None else None),
61+
provider=(str(data["provider"]) if data.get("provider") is not None else None),
62+
raw=data,
63+
)
64+
1965

2066
@dataclass
2167
class PhotoResult:

tests/unit/test_models.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import json
2+
from datetime import timezone
3+
4+
import pytest
5+
6+
from fmd_api.models import Location
7+
8+
9+
def test_location_from_json_dict_basic():
10+
data = {
11+
"lat": 10.5,
12+
"lon": 20.25,
13+
"date": 1600000000000, # ms since epoch
14+
"accuracy": 5.0,
15+
"altitude": 100.0,
16+
"speed": 1.5,
17+
"heading": 180.0,
18+
"bat": 75,
19+
"provider": "gps",
20+
}
21+
loc = Location.from_json(data)
22+
assert loc.lat == 10.5
23+
assert loc.lon == 20.25
24+
assert loc.timestamp is not None
25+
assert loc.timestamp.tzinfo == timezone.utc
26+
assert loc.accuracy_m == 5.0
27+
assert loc.altitude_m == 100.0
28+
assert loc.speed_m_s == 1.5
29+
assert loc.heading_deg == 180.0
30+
assert loc.battery_pct == 75
31+
assert loc.provider == "gps"
32+
assert loc.raw == data
33+
34+
35+
def test_location_from_json_string_basic():
36+
payload = {
37+
"lat": 1.0,
38+
"lon": 2.0,
39+
"date": 1600000000000,
40+
}
41+
loc = Location.from_json(json.dumps(payload))
42+
assert loc.lat == 1.0
43+
assert loc.lon == 2.0
44+
assert loc.timestamp is not None
45+
assert loc.timestamp.tzinfo == timezone.utc
46+
47+
48+
def test_location_from_json_missing_optional_fields():
49+
payload = {"lat": 0.0, "lon": 0.0, "date": 1600000000000}
50+
loc = Location.from_json(payload)
51+
assert loc.accuracy_m is None
52+
assert loc.altitude_m is None
53+
assert loc.speed_m_s is None
54+
assert loc.heading_deg is None
55+
assert loc.battery_pct is None
56+
assert loc.provider is None
57+
58+
59+
def test_location_from_json_invalid_inputs():
60+
with pytest.raises(TypeError):
61+
Location.from_json(123) # type: ignore[arg-type]
62+
63+
with pytest.raises(ValueError):
64+
Location.from_json("not json")
65+
66+
with pytest.raises(ValueError):
67+
Location.from_json({"lat": 1.0}) # missing lon
68+
69+
with pytest.raises(ValueError):
70+
Location.from_json({"lat": 1.0, "lon": 2.0, "date": "abc"})

0 commit comments

Comments
 (0)