feat: add GreenHome web frontend and weather dashboard

This commit is contained in:
2026-09-03 07:48:56 +03:30
parent 434f1dcba3
commit 9eaa5b70d1
220 changed files with 16895 additions and 0 deletions

View 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");
}
}