diff --git a/README.md b/README.md index e69de29..aed623e 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,35 @@ +# TRMNL Weather & Pollen Report + +A custom TRMNL plugin that fetches and displays weather and pollen data. + +## Setup + +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: + ```bash + export WEATHER_API_KEY="your_openweathermap_api_key" + export AUTH_TOKEN="your_chosen_auth_token" + ``` + +3. Run the application: + ```bash + fastapi run main.py --port 8887 + ``` + +## 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 + +Access the API at `http://localhost:8887/?token=your_auth_token` \ No newline at end of file diff --git a/main.py b/main.py index ad465aa..b9a42d9 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ import pprint import zoneinfo from fastapi import FastAPI, HTTPException +from cachetools import TTLCache import httpx @@ -16,6 +17,9 @@ eastern = zoneinfo.ZoneInfo("America/New_York") app = FastAPI() +# Initialize caches for 15 minutes +weather_cache = TTLCache(maxsize=100, ttl=900) # 15 minutes +pollen_cache = TTLCache(maxsize=100, ttl=900) # 15 minutes CONFIG = { # salem, ma @@ -42,19 +46,6 @@ def format_datetime(dt): return f"{format_date(dt)} {format_time(dt)}" -def pollen_desc(index): - if index < 2.5: - return f"{index} l" - elif index < 4.9: - return f"{index} l-m" - elif index < 7.3: - return f"{index} m" - elif index < 9.7: - return f"{index} m-h" - else: - return f"{index} h" - - def relative_day_to_date(rel_dt): dt = datetime.now() day = timedelta(days=1) @@ -69,6 +60,24 @@ def relative_day_to_date(rel_dt): return passthrough +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 + + def fallback_handler(func): @functools.wraps(func) async def wrapper(*args, **kwargs): @@ -84,6 +93,10 @@ def fallback_handler(func): @fallback_handler async def fetch_pollen(zipcode): + # Check the cache first + if zipcode in pollen_cache: + return pollen_cache[zipcode] + url = f"https://www.pollen.com/api/forecast/current/pollen/{zipcode}" headers = { "User-Agent": ( @@ -99,31 +112,40 @@ async def fetch_pollen(zipcode): response = await client.get(url, headers=headers) response.raise_for_status() data = response.json() - return [ + result = [ { "forecast_date": format_datetime( datetime.fromisoformat(data["ForecastDate"]) ), "periods": [ { - "index": pollen_desc(d["Index"]), + "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"] ], } ] + # Cache the result + pollen_cache[zipcode] = result + return result @fallback_handler async def fetch_weather(lat, lon, weather_api_key): + # Check the cache first + cache_key = (lat, lon) + if cache_key in weather_cache: + return weather_cache[cache_key] + url = f"https://api.openweathermap.org/data/3.0/onecall?lat={lat}&lon={lon}&appid={weather_api_key}&units=imperial" async with httpx.AsyncClient() as client: response = await client.get(url) response.raise_for_status() data = response.json() - current, periods = data["current"], data["daily"][:3] - return [ + current, periods = data["current"], data["daily"][:2] + result = [ { "forecast_date": format_datetime(datetime.fromtimestamp(current["dt"])), "current_temp": int(round(current["temp"])), @@ -148,6 +170,9 @@ async def fetch_weather(lat, lon, weather_api_key): ], } ] + # Cache the result + weather_cache[cache_key] = result + return result @app.get("/") @@ -163,8 +188,49 @@ async def read_root(token: str): fetch_weather(CONFIG["lat"], CONFIG["lon"], CONFIG["weather_api_key"]), ) - return { + 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 {} + + # 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"] + } + + today_date = format_date(datetime.now()) + tomorrow_date = format_date(datetime.now() + timedelta(days=1)) + + result = { "fetched_at": format_datetime(datetime.now()), - "pollen": pollen, - "weather": weather, + "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) + if today_data: + result["today"] = today_data + + tomorrow_data = build_daily_data(tomorrow_date, pollen_periods, weather_periods) + if tomorrow_data: + result["tomorrow"] = tomorrow_data + + return result diff --git a/pyproject.toml b/pyproject.toml index 621c953..30a8326 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.13" dependencies = [ "fastapi[standard]>=0.115.12", "httpx>=0.28.1", + "cachetools>=5.0.0", ] [tool.pyright] diff --git a/uv.lock b/uv.lock index 7fb8c1c..6008a3a 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] +[[package]] +name = "cachetools" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/89/817ad5d0411f136c484d535952aef74af9b25e0d99e90cdffbe121e6d628/cachetools-6.1.0.tar.gz", hash = "sha256:b4c4f404392848db3ce7aac34950d17be4d864da4b8b66911008e430bc544587", size = 30714, upload-time = "2025-06-16T18:51:03.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/f0/2ef431fe4141f5e334759d73e81120492b23b2824336883a91ac04ba710b/cachetools-6.1.0-py3-none-any.whl", hash = "sha256:1c7bb3cf9193deaf3508b7c5f2a79986c13ea38965c5adcff1f84519cf39163e", size = 11189, upload-time = "2025-06-16T18:51:01.514Z" }, +] + [[package]] name = "certifi" version = "2025.4.26" @@ -390,12 +399,14 @@ name = "trmnl-report" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "cachetools" }, { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, ] [package.metadata] requires-dist = [ + { name = "cachetools", specifier = ">=5.0.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" }, { name = "httpx", specifier = ">=0.28.1" }, ]