Compare commits
2 commits
d32c4b480c
...
48e4462f86
Author | SHA1 | Date | |
---|---|---|---|
48e4462f86 | |||
012a474742 |
5 changed files with 579 additions and 185 deletions
39
Makefile
Normal file
39
Makefile
Normal file
|
@ -0,0 +1,39 @@
|
|||
.PHONY: help lint format lint-fix check install dev-install clean test
|
||||
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " install Install dependencies"
|
||||
@echo " dev-install Install development dependencies"
|
||||
@echo " lint Run linter (check only)"
|
||||
@echo " format Format code"
|
||||
@echo " lint-fix Run linter with auto-fix"
|
||||
@echo " check Run all checks (lint + format check)"
|
||||
@echo " clean Clean cache files"
|
||||
@echo " test Run tests"
|
||||
|
||||
install:
|
||||
uv sync --no-dev
|
||||
|
||||
dev-install:
|
||||
uv sync
|
||||
|
||||
lint:
|
||||
uv run ruff check .
|
||||
|
||||
format:
|
||||
uv run ruff format .
|
||||
|
||||
lint-fix:
|
||||
uv run ruff check --fix --unsafe-fixes .
|
||||
|
||||
check: lint
|
||||
uv run ruff format --check .
|
||||
|
||||
clean:
|
||||
rm -rf .ruff_cache
|
||||
rm -rf __pycache__
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||
find . -type f -name "*.pyc" -delete
|
||||
|
||||
test:
|
||||
@echo "No tests configured yet"
|
28
README.md
28
README.md
|
@ -1,35 +1,43 @@
|
|||
# TRMNL Weather & Pollen Report
|
||||
# trmnl weather & pollen report
|
||||
|
||||
A custom TRMNL plugin that fetches and displays weather and pollen data.
|
||||
a custom trmnl plugin that fetches and displays weather and pollen data.
|
||||
|
||||
## Setup
|
||||
## setup
|
||||
|
||||
1. Set up a virtual environment and install dependencies:
|
||||
1. set up a virtual environment and install dependencies:
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
uv sync
|
||||
```
|
||||
|
||||
2. Set required environment variables:
|
||||
2. set required environment variables:
|
||||
```bash
|
||||
export WEATHER_API_KEY="your_openweathermap_api_key"
|
||||
export AUTH_TOKEN="your_chosen_auth_token"
|
||||
```
|
||||
|
||||
3. Run the application:
|
||||
3. run the application:
|
||||
```bash
|
||||
fastapi run main.py --port 8887
|
||||
```
|
||||
|
||||
## Docker
|
||||
## development
|
||||
|
||||
Build and run with Docker:
|
||||
### install development dependencies
|
||||
|
||||
```bash
|
||||
make dev-install
|
||||
```
|
||||
|
||||
## docker
|
||||
|
||||
build and run with docker:
|
||||
```bash
|
||||
docker build -t trmnl-report .
|
||||
docker run -p 8887:8887 -e WEATHER_API_KEY=your_key -e AUTH_TOKEN=your_token trmnl-report
|
||||
```
|
||||
|
||||
## API
|
||||
## api
|
||||
|
||||
Access the API at `http://localhost:8887/?token=your_auth_token`
|
||||
access the api at `http://localhost:8887/?token=your_auth_token`
|
615
main.py
615
main.py
|
@ -1,101 +1,391 @@
|
|||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TypedDict
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import pprint
|
||||
import zoneinfo
|
||||
from urllib.parse import urljoin, urlencode
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from cachetools import TTLCache
|
||||
import httpx
|
||||
|
||||
|
||||
pp = pprint.PrettyPrinter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
eastern = zoneinfo.ZoneInfo("America/New_York")
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
weather_cache = TTLCache(maxsize=100, ttl=900) # 15 minutes
|
||||
pollen_cache = TTLCache(maxsize=100, ttl=900) # 15 minutes
|
||||
|
||||
CONFIG = {
|
||||
# salem, ma
|
||||
"zip": "01970",
|
||||
"lat": "42.3554334",
|
||||
"lon": "-71.060511",
|
||||
"weather_api_key": os.environ.get("WEATHER_API_KEY", ""),
|
||||
"auth_token": os.environ["AUTH_TOKEN"],
|
||||
}
|
||||
EASTERN_TZ = zoneinfo.ZoneInfo("America/New_York")
|
||||
CACHE_TTL_SECONDS = 900 # 15 minutes
|
||||
CACHE_MAX_SIZE = 100
|
||||
POLLEN_MAX_INDEX = 12.0
|
||||
HTTP_TIMEOUT = 10.0
|
||||
DEFAULT_ZIP_CODE = "01970" # salem, ma
|
||||
DEFAULT_LATITUDE = "42.3554334"
|
||||
DEFAULT_LONGITUDE = "-71.060511"
|
||||
|
||||
|
||||
def format_date(dt):
|
||||
dt = dt.astimezone(eastern)
|
||||
def get_required_env(key: str) -> str:
|
||||
"""Get required environment variable or raise error."""
|
||||
value = os.environ.get(key)
|
||||
if not value:
|
||||
raise ValueError(f"Required environment variable {key} is not set")
|
||||
return value
|
||||
|
||||
|
||||
class WeatherCondition(TypedDict):
|
||||
description: str
|
||||
main: str
|
||||
id: int
|
||||
icon: str
|
||||
|
||||
|
||||
class CurrentWeather(TypedDict):
|
||||
dt: int
|
||||
sunrise: int
|
||||
sunset: int
|
||||
temp: float
|
||||
feels_like: float
|
||||
pressure: int
|
||||
humidity: int
|
||||
weather: list[WeatherCondition]
|
||||
|
||||
|
||||
class DailyTemperature(TypedDict):
|
||||
min: float
|
||||
max: float
|
||||
|
||||
|
||||
class DailyWeather(TypedDict):
|
||||
dt: int
|
||||
sunrise: int
|
||||
sunset: int
|
||||
temp: DailyTemperature
|
||||
humidity: int
|
||||
pressure: int
|
||||
weather: list[WeatherCondition]
|
||||
|
||||
|
||||
class WeatherApiResponse(TypedDict):
|
||||
current: CurrentWeather
|
||||
daily: list[DailyWeather]
|
||||
|
||||
|
||||
class PollenLocation(TypedDict):
|
||||
periods: list[dict[str, str | int | float]]
|
||||
|
||||
|
||||
class PollenApiResponse(TypedDict):
|
||||
ForecastDate: str
|
||||
Location: PollenLocation
|
||||
|
||||
|
||||
class WeatherPeriod(TypedDict):
|
||||
low: int
|
||||
high: int
|
||||
desc: str
|
||||
humidity: int
|
||||
sunrise: str
|
||||
sunset: str
|
||||
pressure: int
|
||||
period: str
|
||||
|
||||
|
||||
class PollenPeriod(TypedDict):
|
||||
index: int
|
||||
period: str
|
||||
|
||||
|
||||
class WeatherReport(TypedDict):
|
||||
forecast_date: str
|
||||
current_temp: int
|
||||
current_feels_like: int
|
||||
current_humidity: int
|
||||
sunrise: str
|
||||
sunset: str
|
||||
current_pressure: int
|
||||
current_desc: str
|
||||
periods: list[WeatherPeriod]
|
||||
|
||||
|
||||
class PollenReport(TypedDict):
|
||||
forecast_date: str
|
||||
periods: list[PollenPeriod]
|
||||
|
||||
|
||||
class CurrentWeatherData(TypedDict):
|
||||
temp: int
|
||||
feels_like: int
|
||||
desc: str
|
||||
humidity: int
|
||||
pressure: int
|
||||
|
||||
|
||||
class DailyData(TypedDict, total=False):
|
||||
pollen: int
|
||||
low: int
|
||||
high: int
|
||||
desc: str
|
||||
humidity: int
|
||||
sunrise: str
|
||||
sunset: str
|
||||
pressure: int
|
||||
|
||||
|
||||
class FinalReport(TypedDict):
|
||||
fetched_at: str
|
||||
current: CurrentWeatherData
|
||||
today: DailyData
|
||||
tomorrow: DailyData
|
||||
|
||||
|
||||
app = FastAPI(title="trmnl weather & pollen report")
|
||||
|
||||
weather_cache: TTLCache = TTLCache(maxsize=CACHE_MAX_SIZE, ttl=CACHE_TTL_SECONDS)
|
||||
pollen_cache: TTLCache = TTLCache(maxsize=CACHE_MAX_SIZE, ttl=CACHE_TTL_SECONDS)
|
||||
|
||||
|
||||
class Config:
|
||||
"""Application configuration."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
zip_code: str,
|
||||
latitude: str,
|
||||
longitude: str,
|
||||
weather_api_key: str,
|
||||
auth_token: str,
|
||||
):
|
||||
self.zip_code = zip_code
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.weather_api_key = weather_api_key
|
||||
self.auth_token = auth_token
|
||||
|
||||
|
||||
config = Config(
|
||||
zip_code=DEFAULT_ZIP_CODE,
|
||||
latitude=DEFAULT_LATITUDE,
|
||||
longitude=DEFAULT_LONGITUDE,
|
||||
weather_api_key=get_required_env("WEATHER_API_KEY"),
|
||||
auth_token=get_required_env("AUTH_TOKEN"),
|
||||
)
|
||||
|
||||
|
||||
class DateTimeFormatter:
|
||||
"""Utility class for datetime formatting."""
|
||||
|
||||
@staticmethod
|
||||
def format_date(dt: datetime) -> str:
|
||||
"""Format datetime as abbreviated date string (e.g., 'mon 15')."""
|
||||
dt = dt.astimezone(EASTERN_TZ)
|
||||
return dt.strftime("%a %d").lower()
|
||||
|
||||
|
||||
def format_time(dt):
|
||||
dt = dt.astimezone(eastern)
|
||||
@staticmethod
|
||||
def format_time(dt: datetime) -> str:
|
||||
"""Format datetime as time string (e.g., '02:30pm')."""
|
||||
dt = dt.astimezone(EASTERN_TZ)
|
||||
return dt.strftime("%I:%M%p").lower()
|
||||
|
||||
@staticmethod
|
||||
def format_datetime(dt: datetime) -> str:
|
||||
"""Format datetime as date and time string."""
|
||||
return f"{DateTimeFormatter.format_date(dt)} {DateTimeFormatter.format_time(dt)}"
|
||||
|
||||
def format_datetime(dt):
|
||||
dt = dt.astimezone(eastern)
|
||||
return f"{format_date(dt)} {format_time(dt)}"
|
||||
@staticmethod
|
||||
def relative_day_to_date(relative_day: str) -> str:
|
||||
"""Convert relative day string to formatted date string."""
|
||||
now = datetime.now()
|
||||
day_delta = timedelta(days=1)
|
||||
|
||||
relative_day = relative_day.lower().strip()
|
||||
|
||||
if relative_day == "yesterday":
|
||||
return DateTimeFormatter.format_date(now - day_delta)
|
||||
elif relative_day == "today":
|
||||
return DateTimeFormatter.format_date(now)
|
||||
elif relative_day == "tomorrow":
|
||||
return DateTimeFormatter.format_date(now + day_delta)
|
||||
else:
|
||||
return relative_day
|
||||
|
||||
|
||||
def relative_day_to_date(rel_dt):
|
||||
dt = datetime.now()
|
||||
day = timedelta(days=1)
|
||||
match rel_dt.lower().strip():
|
||||
case "yesterday":
|
||||
return format_date(dt - day)
|
||||
case "today":
|
||||
return format_date(dt)
|
||||
case "tomorrow":
|
||||
return format_date(dt + day)
|
||||
case passthrough:
|
||||
return passthrough
|
||||
class WeatherData:
|
||||
"""Data model for weather information."""
|
||||
|
||||
def __init__(self, raw_data: WeatherApiResponse):
|
||||
self.raw_data = raw_data
|
||||
self._validate_data()
|
||||
|
||||
def _validate_data(self) -> None:
|
||||
"""Validate required fields in weather data."""
|
||||
required_fields = ["current", "daily"]
|
||||
for field in required_fields:
|
||||
if field not in self.raw_data:
|
||||
raise ValueError(f"Missing required field in weather data: {field}")
|
||||
|
||||
@property
|
||||
def current(self) -> CurrentWeather:
|
||||
"""Get current weather data."""
|
||||
return self.raw_data["current"]
|
||||
|
||||
@property
|
||||
def daily_periods(self) -> list[DailyWeather]:
|
||||
"""Get daily forecast periods (limited to next 2 days)."""
|
||||
return self.raw_data["daily"][:2]
|
||||
|
||||
|
||||
def build_daily_data(date, pollen_periods, weather_periods):
|
||||
daily_data = {}
|
||||
if date in pollen_periods:
|
||||
daily_data["pollen"] = pollen_periods[date]["index"]
|
||||
if date in weather_periods:
|
||||
weather_data = weather_periods[date]
|
||||
daily_data.update({
|
||||
"low": weather_data["low"],
|
||||
"high": weather_data["high"],
|
||||
"desc": weather_data["desc"],
|
||||
"humidity": weather_data["humidity"],
|
||||
"sunrise": weather_data["sunrise"],
|
||||
"sunset": weather_data["sunset"],
|
||||
"pressure": weather_data["pressure"],
|
||||
})
|
||||
return daily_data
|
||||
class PollenData:
|
||||
"""Data model for pollen information."""
|
||||
|
||||
def __init__(self, raw_data: PollenApiResponse):
|
||||
self.raw_data = raw_data
|
||||
self._validate_data()
|
||||
|
||||
def _validate_data(self) -> None:
|
||||
"""Validate required fields in pollen data."""
|
||||
required_fields = ["ForecastDate", "Location"]
|
||||
for field in required_fields:
|
||||
if field not in self.raw_data:
|
||||
raise ValueError(f"Missing required field in pollen data: {field}")
|
||||
|
||||
@property
|
||||
def forecast_date(self) -> str:
|
||||
"""Get formatted forecast date."""
|
||||
dt = datetime.fromisoformat(self.raw_data["ForecastDate"])
|
||||
return DateTimeFormatter.format_datetime(dt)
|
||||
|
||||
@property
|
||||
def periods(self) -> list[PollenPeriod]:
|
||||
"""Get pollen periods for today and tomorrow."""
|
||||
periods = self.raw_data["Location"].get("periods", [])
|
||||
valid_periods: list[PollenPeriod] = []
|
||||
|
||||
for period in periods:
|
||||
period_type = str(period.get("Type", "")).lower().strip()
|
||||
if period_type in ["today", "tomorrow"]:
|
||||
index_value = float(period.get("Index", 0))
|
||||
# convert pollen index to percentage scale
|
||||
pollen_percentage = int(index_value / POLLEN_MAX_INDEX * 100)
|
||||
|
||||
valid_periods.append(
|
||||
PollenPeriod(
|
||||
{
|
||||
"index": pollen_percentage,
|
||||
"period": DateTimeFormatter.relative_day_to_date(period_type),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
return valid_periods
|
||||
|
||||
|
||||
def fallback_handler(func):
|
||||
def error_handler(func):
|
||||
"""Decorator to handle exceptions in async functions."""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
return await func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
logger.exception(f"Error in {func.__name__}: {e}")
|
||||
return []
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@fallback_handler
|
||||
async def fetch_pollen(zipcode):
|
||||
if zipcode in pollen_cache:
|
||||
return pollen_cache[zipcode]
|
||||
class WeatherService:
|
||||
"""Service for fetching weather data."""
|
||||
|
||||
url = f"https://www.pollen.com/api/forecast/current/pollen/{zipcode}"
|
||||
@staticmethod
|
||||
@error_handler
|
||||
async def fetch_weather(latitude: str, longitude: str, api_key: str) -> list[WeatherReport]:
|
||||
"""Fetch weather data from OpenWeatherMap API."""
|
||||
cache_key = (latitude, longitude)
|
||||
|
||||
if cache_key in weather_cache:
|
||||
return weather_cache[cache_key]
|
||||
|
||||
base_url = "https://api.openweathermap.org/data/3.0/onecall"
|
||||
params = {
|
||||
"lat": latitude,
|
||||
"lon": longitude,
|
||||
"appid": api_key,
|
||||
"units": "imperial",
|
||||
}
|
||||
url = f"{base_url}?{urlencode(params)}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
raw_data = response.json()
|
||||
|
||||
weather_data = WeatherData(raw_data)
|
||||
result = WeatherService._format_weather_data(weather_data)
|
||||
|
||||
weather_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _format_weather_data(weather_data: WeatherData) -> list[WeatherReport]:
|
||||
"""Format weather data for API response."""
|
||||
current = weather_data.current
|
||||
daily_periods = weather_data.daily_periods
|
||||
|
||||
current_dt = datetime.fromtimestamp(current["dt"])
|
||||
|
||||
return [
|
||||
WeatherReport(
|
||||
{
|
||||
"forecast_date": DateTimeFormatter.format_datetime(current_dt),
|
||||
"current_temp": int(round(current["temp"])),
|
||||
"current_feels_like": int(round(current["feels_like"])),
|
||||
"current_humidity": current["humidity"],
|
||||
"sunrise": DateTimeFormatter.format_time(
|
||||
datetime.fromtimestamp(current["sunrise"])
|
||||
),
|
||||
"sunset": DateTimeFormatter.format_time(
|
||||
datetime.fromtimestamp(current["sunset"])
|
||||
),
|
||||
"current_pressure": current["pressure"],
|
||||
"current_desc": current["weather"][0]["description"],
|
||||
"periods": [
|
||||
WeatherService._format_daily_period(period) for period in daily_periods
|
||||
],
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _format_daily_period(period: DailyWeather) -> WeatherPeriod:
|
||||
"""Format a single daily weather period."""
|
||||
period_dt = datetime.fromtimestamp(period["dt"])
|
||||
|
||||
return WeatherPeriod(
|
||||
{
|
||||
"low": int(round(period["temp"]["min"])),
|
||||
"high": int(round(period["temp"]["max"])),
|
||||
"desc": period["weather"][0]["description"],
|
||||
"humidity": period["humidity"],
|
||||
"sunrise": DateTimeFormatter.format_time(datetime.fromtimestamp(period["sunrise"])),
|
||||
"sunset": DateTimeFormatter.format_time(datetime.fromtimestamp(period["sunset"])),
|
||||
"pressure": period["pressure"],
|
||||
"period": DateTimeFormatter.format_date(period_dt),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PollenService:
|
||||
"""Service for fetching pollen data."""
|
||||
|
||||
@staticmethod
|
||||
@error_handler
|
||||
async def fetch_pollen(zip_code: str) -> list[PollenReport]:
|
||||
"""Fetch pollen data from pollen.com API."""
|
||||
if zip_code in pollen_cache:
|
||||
return pollen_cache[zip_code]
|
||||
|
||||
base_url = "https://www.pollen.com/api/forecast/current/pollen/"
|
||||
url = urljoin(base_url, zip_code)
|
||||
headers = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
|
@ -104,127 +394,142 @@ async def fetch_pollen(zipcode):
|
|||
),
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Referer": url,
|
||||
"Cookie": f"geo={zipcode}",
|
||||
"Cookie": f"geo={zip_code}",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
raw_data = response.json()
|
||||
|
||||
pollen_data = PollenData(raw_data)
|
||||
result = [
|
||||
PollenReport(
|
||||
{
|
||||
"forecast_date": format_datetime(
|
||||
datetime.fromisoformat(data["ForecastDate"])
|
||||
),
|
||||
"periods": [
|
||||
{
|
||||
"index": int(d["Index"] / 12. * 100),
|
||||
"period": relative_day_to_date(d["Type"]),
|
||||
}
|
||||
for d in data["Location"]["periods"]
|
||||
if d["Type"].lower().strip() in ["today", "tomorrow"]
|
||||
],
|
||||
"forecast_date": pollen_data.forecast_date,
|
||||
"periods": pollen_data.periods,
|
||||
}
|
||||
)
|
||||
]
|
||||
pollen_cache[zipcode] = result
|
||||
|
||||
pollen_cache[zip_code] = result
|
||||
return result
|
||||
|
||||
|
||||
@fallback_handler
|
||||
async def fetch_weather(lat, lon, weather_api_key):
|
||||
cache_key = (lat, lon)
|
||||
if cache_key in weather_cache:
|
||||
return weather_cache[cache_key]
|
||||
class DataAggregator:
|
||||
"""Service for aggregating weather and pollen data."""
|
||||
|
||||
url = f"https://api.openweathermap.org/data/3.0/onecall?lat={lat}&lon={lon}&appid={weather_api_key}&units=imperial"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
current, periods = data["current"], data["daily"][:2]
|
||||
result = [
|
||||
@staticmethod
|
||||
def build_daily_data(
|
||||
date: str,
|
||||
pollen_periods: dict[str, PollenPeriod],
|
||||
weather_periods: dict[str, WeatherPeriod],
|
||||
) -> DailyData:
|
||||
"""Build daily data combining weather and pollen information."""
|
||||
daily_data: DailyData = {}
|
||||
|
||||
if date in pollen_periods:
|
||||
daily_data["pollen"] = pollen_periods[date]["index"]
|
||||
|
||||
if date in weather_periods:
|
||||
weather_data = weather_periods[date]
|
||||
daily_data.update(
|
||||
{
|
||||
"forecast_date": format_datetime(datetime.fromtimestamp(current["dt"])),
|
||||
"current_temp": int(round(current["temp"])),
|
||||
"current_feels_like": int(round(current["feels_like"])),
|
||||
"current_humidity": current["humidity"],
|
||||
"sunrise": format_time(datetime.fromtimestamp(current["sunrise"])),
|
||||
"sunset": format_time(datetime.fromtimestamp(current["sunset"])),
|
||||
"current_pressure": current["pressure"],
|
||||
"current_desc": current["weather"][0]["description"],
|
||||
"periods": [
|
||||
"low": weather_data["low"],
|
||||
"high": weather_data["high"],
|
||||
"desc": weather_data["desc"],
|
||||
"humidity": weather_data["humidity"],
|
||||
"sunrise": weather_data["sunrise"],
|
||||
"sunset": weather_data["sunset"],
|
||||
"pressure": weather_data["pressure"],
|
||||
}
|
||||
)
|
||||
|
||||
return daily_data
|
||||
|
||||
@staticmethod
|
||||
def create_periods_lookup(
|
||||
pollen_data: list[PollenReport], weather_data: list[WeatherReport]
|
||||
) -> tuple[dict[str, PollenPeriod], dict[str, WeatherPeriod], CurrentWeatherData]:
|
||||
"""Create lookup dictionaries for pollen and weather periods."""
|
||||
pollen_periods: dict[str, PollenPeriod] = {}
|
||||
weather_periods: dict[str, WeatherPeriod] = {}
|
||||
|
||||
if pollen_data and pollen_data[0].get("periods"):
|
||||
pollen_periods = {p["period"]: p for p in pollen_data[0]["periods"]}
|
||||
|
||||
if weather_data and weather_data[0].get("periods"):
|
||||
weather_periods = {p["period"]: p for p in weather_data[0]["periods"]}
|
||||
|
||||
current_weather_info = CurrentWeatherData(
|
||||
{
|
||||
"low": int(round(p["temp"]["min"])),
|
||||
"high": int(round(p["temp"]["max"])),
|
||||
"desc": p["weather"][0]["description"],
|
||||
"humidity": p["humidity"],
|
||||
"sunrise": format_time(datetime.fromtimestamp(p["sunrise"])),
|
||||
"sunset": format_time(datetime.fromtimestamp(p["sunset"])),
|
||||
"pressure": p["pressure"],
|
||||
"period": format_date(datetime.fromtimestamp(p["dt"])),
|
||||
"temp": 0,
|
||||
"feels_like": 0,
|
||||
"desc": "",
|
||||
"humidity": 0,
|
||||
"pressure": 0,
|
||||
}
|
||||
for p in periods
|
||||
],
|
||||
)
|
||||
|
||||
if weather_data and weather_data[0]:
|
||||
data = weather_data[0]
|
||||
current_weather_info = CurrentWeatherData(
|
||||
{
|
||||
"temp": data["current_temp"],
|
||||
"feels_like": data["current_feels_like"],
|
||||
"desc": data["current_desc"],
|
||||
"humidity": data["current_humidity"],
|
||||
"pressure": data["current_pressure"],
|
||||
}
|
||||
]
|
||||
weather_cache[cache_key] = result
|
||||
return result
|
||||
)
|
||||
|
||||
return pollen_periods, weather_periods, current_weather_info
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def read_root(token: str):
|
||||
if token != CONFIG["auth_token"]:
|
||||
raise HTTPException(status_code=403, detail="unauthorized")
|
||||
async def get_weather_pollen_report(token: str) -> FinalReport:
|
||||
"""
|
||||
Get weather and pollen report.
|
||||
|
||||
[
|
||||
pollen,
|
||||
weather,
|
||||
] = await asyncio.gather(
|
||||
fetch_pollen(CONFIG["zip"]),
|
||||
fetch_weather(CONFIG["lat"], CONFIG["lon"], CONFIG["weather_api_key"]),
|
||||
Args:
|
||||
token: Authentication token
|
||||
|
||||
Returns:
|
||||
Dictionary containing current, today, and tomorrow weather/pollen data
|
||||
|
||||
Raises:
|
||||
HTTPException: If authentication fails
|
||||
"""
|
||||
if token != config.auth_token:
|
||||
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||
|
||||
pollen_data, weather_data = await asyncio.gather(
|
||||
PollenService.fetch_pollen(config.zip_code),
|
||||
WeatherService.fetch_weather(config.latitude, config.longitude, config.weather_api_key),
|
||||
)
|
||||
|
||||
pollen_periods = {p["period"]: p for p in pollen[0]["periods"]} if pollen and pollen[0]["periods"] else {}
|
||||
weather_periods = {p["period"]: p for p in weather[0]["periods"]} if weather and weather[0]["periods"] else {}
|
||||
pollen_periods, weather_periods, current_weather_info = DataAggregator.create_periods_lookup(
|
||||
pollen_data, weather_data
|
||||
)
|
||||
|
||||
# Add current weather data as a "current" period
|
||||
if weather and weather[0]:
|
||||
data = weather[0]
|
||||
weather_periods["current"] = {
|
||||
"period": "current",
|
||||
"temp": data["current_temp"],
|
||||
"feels_like": data["current_feels_like"],
|
||||
"humidity": data["current_humidity"],
|
||||
"pressure": data["current_pressure"],
|
||||
"desc": data["current_desc"],
|
||||
"sunrise": data["sunrise"],
|
||||
"sunset": data["sunset"]
|
||||
now = datetime.now()
|
||||
today_date = DateTimeFormatter.format_date(now)
|
||||
tomorrow_date = DateTimeFormatter.format_date(now + timedelta(days=1))
|
||||
|
||||
result: FinalReport = FinalReport(
|
||||
{
|
||||
"fetched_at": DateTimeFormatter.format_datetime(now),
|
||||
"current": current_weather_info,
|
||||
"today": DailyData({}),
|
||||
"tomorrow": DailyData({}),
|
||||
}
|
||||
)
|
||||
|
||||
today_date = format_date(datetime.now())
|
||||
tomorrow_date = format_date(datetime.now() + timedelta(days=1))
|
||||
|
||||
result = {
|
||||
"fetched_at": format_datetime(datetime.now()),
|
||||
"current": {},
|
||||
"today": {},
|
||||
"tomorrow": {},
|
||||
}
|
||||
|
||||
if "current" in weather_periods:
|
||||
weather_data = weather_periods["current"]
|
||||
result["current"] = {
|
||||
"temp": weather_data["temp"],
|
||||
"feels_like": weather_data["feels_like"],
|
||||
"desc": weather_data["desc"],
|
||||
"humidity": weather_data["humidity"],
|
||||
"pressure": weather_data["pressure"]
|
||||
}
|
||||
|
||||
today_data = build_daily_data(today_date, pollen_periods, weather_periods)
|
||||
today_data = DataAggregator.build_daily_data(today_date, pollen_periods, weather_periods)
|
||||
if today_data:
|
||||
result["today"] = today_data
|
||||
|
||||
tomorrow_data = build_daily_data(tomorrow_date, pollen_periods, weather_periods)
|
||||
tomorrow_data = DataAggregator.build_daily_data(tomorrow_date, pollen_periods, weather_periods)
|
||||
if tomorrow_data:
|
||||
result["tomorrow"] = tomorrow_data
|
||||
|
||||
|
|
|
@ -10,6 +10,14 @@ dependencies = [
|
|||
"cachetools>=5.0.0",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
line-length = 100
|
||||
indent-width = 4
|
||||
|
||||
[tool.pyright]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["ruff>=0.12.11"]
|
36
uv.lock
generated
36
uv.lock
generated
|
@ -1,5 +1,5 @@
|
|||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
|
@ -364,6 +364,32 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/2e/3c/7a824c0514e87c61000583ac22c8321da6dc8e58a93d5f56e583482a2ee0/rich_toolkit-0.14.6-py3-none-any.whl", hash = "sha256:764f3a5f9e4b539ce805596863299e8982599514906dc5e3ccc2d390ef74c301", size = 24815, upload-time = "2025-05-12T19:19:13.713Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.12.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/55/16ab6a7d88d93001e1ae4c34cbdcfb376652d761799459ff27c1dc20f6fa/ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d", size = 5347103, upload-time = "2025-08-28T13:59:08.87Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a2/3b3573e474de39a7a475f3fbaf36a25600bfeb238e1a90392799163b64a0/ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065", size = 11979885, upload-time = "2025-08-28T13:58:26.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/e4/235ad6d1785a2012d3ded2350fd9bc5c5af8c6f56820e696b0118dfe7d24/ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93", size = 12742364, upload-time = "2025-08-28T13:58:30.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/0d/15b72c5fe6b1e402a543aa9d8960e0a7e19dfb079f5b0b424db48b7febab/ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd", size = 11920111, upload-time = "2025-08-28T13:58:33.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/c0/f66339d7893798ad3e17fa5a1e587d6fd9806f7c1c062b63f8b09dda6702/ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee", size = 12160060, upload-time = "2025-08-28T13:58:35.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/69/9870368326db26f20c946205fb2d0008988aea552dbaec35fbacbb46efaa/ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0", size = 11799848, upload-time = "2025-08-28T13:58:38.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8c/dd2c7f990e9b3a8a55eee09d4e675027d31727ce33cdb29eab32d025bdc9/ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644", size = 13536288, upload-time = "2025-08-28T13:58:40.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/30/d5496fa09aba59b5e01ea76775a4c8897b13055884f56f1c35a4194c2297/ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211", size = 14490633, upload-time = "2025-08-28T13:58:42.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/2f/81f998180ad53445d403c386549d6946d0748e536d58fce5b5e173511183/ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793", size = 13888430, upload-time = "2025-08-28T13:58:44.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/71/23a0d1d5892a377478c61dbbcffe82a3476b050f38b5162171942a029ef3/ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee", size = 12913133, upload-time = "2025-08-28T13:58:47.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8", size = 13169082, upload-time = "2025-08-28T13:58:49.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/b5/68b3ff96160d8b49e8dd10785ff3186be18fd650d356036a3770386e6c7f/ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f", size = 13139490, upload-time = "2025-08-28T13:58:51.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/b9/050a3278ecd558f74f7ee016fbdf10591d50119df8d5f5da45a22c6afafc/ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000", size = 11958928, upload-time = "2025-08-28T13:58:53.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/bc/93be37347db854806904a43b0493af8d6873472dfb4b4b8cbb27786eb651/ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2", size = 11764513, upload-time = "2025-08-28T13:58:55.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/a1/1471751e2015a81fd8e166cd311456c11df74c7e8769d4aabfbc7584c7ac/ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39", size = 12745154, upload-time = "2025-08-28T13:58:58.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ab/2542b14890d0f4872dd81b7b2a6aed3ac1786fae1ce9b17e11e6df9e31e3/ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9", size = 13227653, upload-time = "2025-08-28T13:59:00.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/16/2fbfc61047dbfd009c58a28369a693a1484ad15441723be1cd7fe69bb679/ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3", size = 11944270, upload-time = "2025-08-28T13:59:02.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a5/34276984705bfe069cd383101c45077ee029c3fe3b28225bf67aa35f0647/ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd", size = 13046600, upload-time = "2025-08-28T13:59:04.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290, upload-time = "2025-08-28T13:59:06.933Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
|
@ -404,6 +430,11 @@ dependencies = [
|
|||
{ name = "httpx" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "cachetools", specifier = ">=5.0.0" },
|
||||
|
@ -411,6 +442,9 @@ requires-dist = [
|
|||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff", specifier = ">=0.12.11" }]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.16.0"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue