Skip to content

Commit 92d81e0

Browse files
committed
Fixed tile location save
1 parent 502ad3a commit 92d81e0

16 files changed

Lines changed: 1244 additions & 242 deletions

File tree

CLAUDE.md

Lines changed: 24 additions & 194 deletions
Large diffs are not rendered by default.

app/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from fastapi.templating import Jinja2Templates
99

1010
from app.config import PROJECT_ROOT, settings
11-
from app.routers import ha_proxy, layout, weather
11+
from app.routers import ha_proxy, layout, moon, weather
1212

1313
logging.basicConfig(
1414
level=logging.DEBUG if settings.debug else logging.INFO,
@@ -30,6 +30,7 @@
3030
app.include_router(ha_proxy.router)
3131
app.include_router(layout.router)
3232
app.include_router(weather.router)
33+
app.include_router(moon.router)
3334

3435

3536
# ---------- Pages ----------

app/models.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,34 @@ def _migrate_legacy_members(cls, data):
7676
return data
7777

7878

79+
class ClockTile(BaseModel):
80+
"""A dashboard tile displaying the current time, date, and day of week."""
81+
82+
tile_type: Literal["clock"] = "clock"
83+
id: str = Field(description="Unique tile identifier")
84+
label: str = Field(default="Clock", description="Display label shown on the tile")
85+
format_24h: bool = Field(default=False, description="Use 24-hour format")
86+
show_seconds: bool = Field(default=False, description="Display seconds")
87+
x: int = Field(default=0, ge=0, description="Grid column position")
88+
y: int = Field(default=0, ge=0, description="Grid row position")
89+
w: int = Field(default=2, ge=1, description="Width in grid units")
90+
h: int = Field(default=2, ge=1, description="Height in grid units")
91+
92+
93+
class MoonTile(BaseModel):
94+
"""A dashboard tile showing lunar phase, illumination, and rise/set times."""
95+
96+
tile_type: Literal["moon"] = "moon"
97+
id: str = Field(description="Unique tile identifier")
98+
label: str = Field(default="Moon", description="Display label shown on the tile")
99+
zip_code: str = Field(description="ZIP / postal code for moonrise/set times")
100+
country_code: str = Field(default="US", description="ISO 3166-1 alpha-2 country code")
101+
x: int = Field(default=0, ge=0, description="Grid column position")
102+
y: int = Field(default=0, ge=0, description="Grid row position")
103+
w: int = Field(default=2, ge=1, description="Width in grid units")
104+
h: int = Field(default=2, ge=1, description="Height in grid units")
105+
106+
79107
class ForecastChartTile(BaseModel):
80108
"""A dashboard tile showing a rain or temperature chart for a ZIP code."""
81109

@@ -95,7 +123,7 @@ class ForecastChartTile(BaseModel):
95123

96124
# Discriminated union — tile_type field selects the concrete model.
97125
AnyTile = Annotated[
98-
Union[EntityTile, WeatherTile, SceneTile, ForecastChartTile],
126+
Union[EntityTile, WeatherTile, SceneTile, ForecastChartTile, MoonTile, ClockTile],
99127
Field(discriminator="tile_type"),
100128
]
101129

app/routers/moon.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Moon phase data routes.
2+
3+
Computes lunar phase/illumination/age locally using the synodic month cycle.
4+
Moonrise/moonset/transit times are fetched from the US Naval Observatory API
5+
(free, no API key). Geocoding (ZIP -> lat/lon) reuses the weather module's
6+
_geocode helper. Responses are cached in-process for 1 hour.
7+
"""
8+
9+
import datetime
10+
import logging
11+
import math
12+
import time
13+
from typing import Any, Dict, List, Optional, Tuple
14+
15+
import httpx
16+
from fastapi import APIRouter, Query
17+
18+
from app.routers.weather import _geocode
19+
20+
logger = logging.getLogger(__name__)
21+
router = APIRouter(prefix="/api/moon", tags=["moon"])
22+
23+
# In-process cache: cache_key -> {"expires": float, "data": dict}
24+
_moon_cache: Dict[str, Any] = {}
25+
_CACHE_TTL = 3600 # 1 hour
26+
27+
# Known new moon reference: January 6, 2000 18:14 UTC
28+
_NEW_MOON_REF = datetime.datetime(2000, 1, 6, 18, 14, 0, tzinfo=datetime.timezone.utc)
29+
_SYNODIC_MONTH = 29.53058770576 # days
30+
31+
# Phase names and their ranges (in fraction of synodic month).
32+
# Key phases (new, quarters, full) get a ±2% window; intermediate phases fill the rest.
33+
_PHASE_NAMES: List[Tuple[float, str]] = [
34+
(0.0, "New Moon"),
35+
(0.02, "Waxing Crescent"),
36+
(0.23, "First Quarter"),
37+
(0.27, "Waxing Gibbous"),
38+
(0.48, "Full Moon"),
39+
(0.52, "Waning Gibbous"),
40+
(0.73, "Last Quarter"),
41+
(0.77, "Waning Crescent"),
42+
(0.98, "New Moon"),
43+
(1.0, "New Moon"),
44+
]
45+
46+
47+
def _compute_moon_phase(dt: datetime.datetime) -> Dict[str, Any]:
48+
"""Compute moon phase, illumination, and age from the synodic month cycle.
49+
50+
Returns dict with keys: phase, illumination (0-100), age (days), fraction (0-1).
51+
"""
52+
if dt.tzinfo is None:
53+
dt = dt.replace(tzinfo=datetime.timezone.utc)
54+
55+
diff = (dt - _NEW_MOON_REF).total_seconds() / 86400.0
56+
cycles = diff / _SYNODIC_MONTH
57+
position = cycles % 1.0 # 0..1 position in current cycle
58+
age = position * _SYNODIC_MONTH
59+
60+
# Illumination: 0 at new moon, 1 at full moon, using cosine approximation
61+
illumination = (1 - math.cos(2 * math.pi * position)) / 2
62+
63+
# Determine phase name
64+
phase_name = "New Moon"
65+
for i in range(len(_PHASE_NAMES) - 1):
66+
if _PHASE_NAMES[i][0] <= position < _PHASE_NAMES[i + 1][0]:
67+
phase_name = _PHASE_NAMES[i][1]
68+
break
69+
70+
return {
71+
"phase": phase_name,
72+
"illumination": round(illumination * 100, 1),
73+
"age": round(age, 2),
74+
"fraction": round(illumination, 4),
75+
}
76+
77+
78+
def _relative_time(time_str: str) -> str:
79+
"""Return a human-friendly relative description like 'in 3 hours' or '2 hours ago'.
80+
81+
Expects time_str in "HH:MM" 24-hour format. Comparison is against local time.
82+
"""
83+
try:
84+
now = datetime.datetime.now()
85+
hour, minute = map(int, time_str.split(":"))
86+
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
87+
diff_minutes = int((target - now).total_seconds() / 60)
88+
89+
if abs(diff_minutes) < 2:
90+
return "now"
91+
92+
if diff_minutes > 0:
93+
if diff_minutes < 60:
94+
return f"in {diff_minutes} min"
95+
hours = diff_minutes // 60
96+
return f"in {hours} hr" if hours == 1 else f"in {hours} hrs"
97+
else:
98+
ago = -diff_minutes
99+
if ago < 60:
100+
return f"{ago} min ago"
101+
hours = ago // 60
102+
return f"{hours} hr ago" if hours == 1 else f"{hours} hrs ago"
103+
except (ValueError, TypeError):
104+
return ""
105+
106+
107+
async def _fetch_usno(
108+
lat: float, lon: float, date_str: str
109+
) -> Optional[Dict[str, str]]:
110+
"""Fetch moonrise/moonset/transit times from US Naval Observatory API.
111+
112+
Returns a dict with 'Rise', 'Set', 'Upper Transit' keys mapped to 'HH:MM'
113+
strings, or None if the request fails (graceful degradation).
114+
"""
115+
url = "https://aa.usno.navy.mil/api/rstt/oneday"
116+
params = {"date": date_str, "coords": f"{lat},{lon}"}
117+
try:
118+
async with httpx.AsyncClient(timeout=10) as client:
119+
resp = await client.get(url, params=params)
120+
except (httpx.ConnectError, httpx.TimeoutException):
121+
logger.warning("USNO API unavailable — returning phase data only")
122+
return None
123+
124+
if resp.status_code != 200:
125+
logger.warning("USNO API returned %s — returning phase data only", resp.status_code)
126+
return None
127+
128+
data = resp.json()
129+
properties = data.get("properties", {}).get("data", {})
130+
moon_data = properties.get("moondata", [])
131+
132+
result: Dict[str, str] = {}
133+
for entry in moon_data:
134+
phen = entry.get("phen", "")
135+
time_val = entry.get("time", "")
136+
if phen == "Rise":
137+
result["Rise"] = time_val
138+
elif phen == "Set":
139+
result["Set"] = time_val
140+
elif phen in ("Upper Transit", "U. Transit"):
141+
result["Upper Transit"] = time_val
142+
return result
143+
144+
145+
@router.get("")
146+
async def get_moon(
147+
zip_code: str = Query(..., description="ZIP / postal code"),
148+
country_code: str = Query(default="US", description="ISO 3166-1 alpha-2 country code"),
149+
) -> Dict[str, Any]:
150+
"""Return moon phase, illumination, age, and rise/set/transit times for a ZIP code.
151+
152+
Phase data is computed locally (no external API). Rise/set/transit times
153+
come from the US Naval Observatory API (graceful degradation if unavailable).
154+
Responses are cached server-side for 1 hour.
155+
"""
156+
cache_key = f"moon:{zip_code.strip()}:{country_code.upper()}"
157+
now = time.time()
158+
if cache_key in _moon_cache and _moon_cache[cache_key]["expires"] > now:
159+
logger.debug("Moon cache hit for %s", cache_key)
160+
return _moon_cache[cache_key]["data"]
161+
162+
logger.info("Fetching moon data for zip=%s country=%s", zip_code, country_code)
163+
lat, lon = await _geocode(zip_code.strip(), country_code.strip())
164+
165+
today = datetime.date.today()
166+
date_str = today.strftime("%Y-%m-%d")
167+
168+
moon = _compute_moon_phase(datetime.datetime.now(datetime.timezone.utc))
169+
usno = await _fetch_usno(lat, lon, date_str)
170+
171+
moonrise = (usno or {}).get("Rise", "")
172+
moonset = (usno or {}).get("Set", "")
173+
transit = (usno or {}).get("Upper Transit", "")
174+
175+
data: Dict[str, Any] = {
176+
"phase": moon["phase"],
177+
"illumination": moon["illumination"],
178+
"age": moon["age"],
179+
"moonrise": moonrise,
180+
"moonrise_relative": _relative_time(moonrise) if moonrise else "",
181+
"moonset": moonset,
182+
"moonset_relative": _relative_time(moonset) if moonset else "",
183+
"moon_transit": transit,
184+
"moon_transit_relative": _relative_time(transit) if transit else "",
185+
"fraction": moon["fraction"],
186+
}
187+
188+
_moon_cache[cache_key] = {"expires": now + _CACHE_TTL, "data": data}
189+
return data

0 commit comments

Comments
 (0)