feat: add GreenHome web frontend and weather dashboard
This commit is contained in:
154
src/GreenHome.Api/Controllers/WeatherController.cs
Normal file
154
src/GreenHome.Api/Controllers/WeatherController.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace GreenHome.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Proxy for external weather services (Open-Meteo / Nominatim) with tiered caching:
|
||||
/// - current conditions : 30 minutes
|
||||
/// - forecast : 6 hours
|
||||
/// - historical archive : 30 days (historical data never changes)
|
||||
/// - reverse geocoding : 30 days (location name rarely changes)
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class WeatherController : ControllerBase
|
||||
{
|
||||
private const double DefaultLatitude = 34.39674800; // Kahak Qom
|
||||
private const double DefaultLongitude = 50.86594800; // Kahak Qom
|
||||
|
||||
private static readonly TimeSpan CurrentTtl = TimeSpan.FromMinutes(30);
|
||||
private static readonly TimeSpan ForecastTtl = TimeSpan.FromHours(6);
|
||||
private static readonly TimeSpan HistoricalTtl = TimeSpan.FromDays(30);
|
||||
private static readonly TimeSpan LocationTtl = TimeSpan.FromDays(30);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<WeatherController> _logger;
|
||||
|
||||
public WeatherController(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache,
|
||||
ILogger<WeatherController> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Current conditions only (temperature, humidity, wind, weather code). Cached 30 minutes.</summary>
|
||||
[HttpGet("current")]
|
||||
public Task<IActionResult> Current(
|
||||
[FromQuery] double latitude = DefaultLatitude,
|
||||
[FromQuery] double longitude = DefaultLongitude,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string query = "current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&timezone=Asia/Tehran";
|
||||
return GetOrAddAsync(
|
||||
$"weather:current:{latitude}:{longitude}",
|
||||
"https://api.open-meteo.com/v1/forecast",
|
||||
$"latitude={latitude}&longitude={longitude}&{query}",
|
||||
CurrentTtl,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Hourly + daily forecast (no current, which is fetched separately). Cached 6 hours.</summary>
|
||||
[HttpGet("forecast")]
|
||||
public Task<IActionResult> Forecast(
|
||||
[FromQuery] double latitude = DefaultLatitude,
|
||||
[FromQuery] double longitude = DefaultLongitude,
|
||||
[FromQuery] int forecastDays = 7,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string query = "hourly=temperature_2m,relative_humidity_2m,weather_code,precipitation"
|
||||
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max,uv_index_max,sunshine_duration,wind_speed_10m_max"
|
||||
+ "&timezone=Asia/Tehran";
|
||||
|
||||
return GetOrAddAsync(
|
||||
$"weather:forecast:{latitude}:{longitude}:{forecastDays}",
|
||||
"https://api.open-meteo.com/v1/forecast",
|
||||
$"latitude={latitude}&longitude={longitude}&{query}&forecast_days={forecastDays}",
|
||||
ForecastTtl,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Historical archive data for a past date range. Cached 30 days.</summary>
|
||||
[HttpGet("historical")]
|
||||
public Task<IActionResult> Historical(
|
||||
[FromQuery] double latitude = DefaultLatitude,
|
||||
[FromQuery] double longitude = DefaultLongitude,
|
||||
[FromQuery] string? start_date = null,
|
||||
[FromQuery] string? end_date = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string query = "daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,sunshine_duration&timezone=Asia/Tehran";
|
||||
|
||||
var cacheKey = $"weather:historical:{latitude}:{longitude}:{start_date}:{end_date}";
|
||||
var urlQuery = $"latitude={latitude}&longitude={longitude}&start_date={start_date}&end_date={end_date}&{query}";
|
||||
|
||||
return GetOrAddAsync(
|
||||
cacheKey,
|
||||
"https://archive-api.open-meteo.com/v1/archive",
|
||||
urlQuery,
|
||||
HistoricalTtl,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Reverse geocoding (coordinates -> location name). Cached 30 days.</summary>
|
||||
[HttpGet("location")]
|
||||
public Task<IActionResult> Location(
|
||||
[FromQuery] double latitude = DefaultLatitude,
|
||||
[FromQuery] double longitude = DefaultLongitude,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = $"weather:location:{latitude}:{longitude}";
|
||||
var url = $"https://nominatim.openstreetmap.org/reverse?format=json&lat={latitude}&lon={longitude}&zoom=10&addressdetails=1&accept-language=fa,en";
|
||||
|
||||
return GetOrAddAsync(
|
||||
cacheKey,
|
||||
url,
|
||||
null,
|
||||
LocationTtl,
|
||||
cancellationToken,
|
||||
userAgent: "GreenHome/1.0 (greenhouse weather proxy)");
|
||||
}
|
||||
|
||||
private async Task<IActionResult> GetOrAddAsync(
|
||||
string cacheKey,
|
||||
string baseUrl,
|
||||
string? queryString,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken,
|
||||
string? userAgent = null)
|
||||
{
|
||||
if (_cache.TryGetValue(cacheKey, out string? cachedJson))
|
||||
{
|
||||
return Content(cachedJson!, "application/json");
|
||||
}
|
||||
|
||||
var url = string.IsNullOrEmpty(queryString) ? baseUrl : $"{baseUrl}?{queryString}";
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
if (!string.IsNullOrEmpty(userAgent))
|
||||
{
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
}
|
||||
|
||||
var response = await client.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Upstream weather service returned {StatusCode} for {Url}",
|
||||
(int)response.StatusCode,
|
||||
url);
|
||||
return StatusCode((int)response.StatusCode, "Upstream weather service error");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_cache.Set(cacheKey, json, ttl);
|
||||
|
||||
return Content(json, "application/json");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user