Add image generation

This commit is contained in:
2025-07-15 02:42:16 +02:00
parent 9cfbdc21d0
commit 480a38baac
17 changed files with 635 additions and 12 deletions

View File

@@ -12,7 +12,18 @@ public class HomeController(IMediator mediator) : ControllerBase
[HttpGet(Name = "GetHome")]
public async Task<ActionResult<WeatherInformation>> Get()
{
var result = await mediator.Send(new GetWeather.Command());
return Ok(result);
return Ok(await mediator.Send(new Weather.Command()));
}
[HttpGet("default.png")]
public async Task<IActionResult> GetImage()
{
return File(await mediator.Send(new ImageGeneration.Command()), "image/png");
}
[HttpGet("departureboard")]
public async Task<ActionResult<List<TimeTable>>> GetDepartureBoard()
{
return Ok(await mediator.Send(new DepartureBoard.Command()));
}
}

View File

@@ -126,4 +126,22 @@ public static class ContractExtensions
};
}
public static List<TimeTable>? ToContract(this TrafikLabsApiResponse response)
{
if (response?.Departure is null)
return [];
return response.Departure.Select(dep => new TimeTable
{
LineNumber = dep.ProductAtStop?.DisplayNumber ?? dep.ProductAtStop?.Line,
LineName = dep.ProductAtStop?.Name,
TransportType = dep.ProductAtStop?.CatOutL,
Operator = dep.ProductAtStop?.Operator,
StopName = dep.Stop,
DepartureTime = $"{dep.Date} {dep.Time}",
Direction = dep.Direction,
JourneyDetailRef = dep.JourneyDetailRef?.Ref,
Notes = dep.Notes?.Note?.Select(n => n.Value).ToList() ?? []
}).ToList();
}
}

View File

@@ -0,0 +1,25 @@
using HomeApi.Integration;
using HomeApi.Models;
using MediatR;
namespace HomeApi.Handlers;
public static class DepartureBoard
{
public record Command : IRequest<List<TimeTable>>;
public class Handler : IRequestHandler<Command, List<TimeTable>>
{
private readonly IDepartureBoardService _departureBoardService;
public Handler(IDepartureBoardService departureBoardService)
{
_departureBoardService = departureBoardService;
}
public async Task<List<TimeTable>> Handle(Command request, CancellationToken cancellationToken)
{
return await _departureBoardService.GetDepartureBoard() ?? new List<TimeTable>();
}
}
}

View File

@@ -0,0 +1,75 @@
using System.Reflection;
using HomeApi.Models;
using HomeApi.Models.Configuration;
using MediatR;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Extensions.Options;
using PuppeteerSharp;
using RazorLight;
namespace HomeApi.Handlers;
public static class ImageGeneration
{
public record Command : IRequest<Stream>;
public class Handler : IRequestHandler<Command, Stream>
{
private readonly ILogger<Handler> _logger;
private readonly IWebHostEnvironment _env;
private readonly IMediator _mediator;
public Handler(
IOptions<ApiConfiguration> apiConfiguration,
ILogger<Handler> logger, IWebHostEnvironment env, IMediator mediator)
{
_logger = logger;
_env = env;
_mediator = mediator;
}
public async Task<Stream> Handle(Command request, CancellationToken cancellationToken)
{
var weather = await _mediator.Send(new Weather.Command(), cancellationToken);
var departureBoard = await _mediator.Send(new DepartureBoard.Command(), cancellationToken);
var model = new Image
{
Weather = weather,
TimeTable = departureBoard
};
if(weather is null)
throw new Exception("Weather data not found");
var engine = new RazorLightEngineBuilder().SetOperatingAssembly(Assembly.GetExecutingAssembly())
.UseEmbeddedResourcesProject(typeof(ImageGeneration)).UseMemoryCachingProvider().Build();
var path = Path.Combine(_env.WebRootPath, "index.cshtml");
var template = await File.ReadAllTextAsync(path);
var result = await engine.CompileRenderStringAsync("templateKey", template, model);
return await CreateImage(result);
}
private static async Task<Stream> CreateImage(string htmlContent)
{
var browserFetcher = new BrowserFetcher();
await browserFetcher.DownloadAsync();
var browser = await Puppeteer.LaunchAsync(new LaunchOptions
{
Headless = true,
Args = ["--disable-gpu"]
});
var page = await browser.NewPageAsync();
await page.SetViewportAsync(new ViewPortOptions
{
Width = 800,
Height = 480
});
await page.SetContentAsync(htmlContent, new NavigationOptions { WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } });
var stream = await page.ScreenshotStreamAsync(new ScreenshotOptions { Type = ScreenshotType.Png });
return stream;
}
}
}

View File

@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
namespace HomeApi.Handlers;
public static class GetWeather
public static class Weather
{
public record Command : IRequest<WeatherInformation>;

View File

@@ -5,12 +5,15 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<PreserveCompilationContext>true</PreserveCompilationContext>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="13.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/>
<PackageReference Include="PuppeteerSharp" Version="20.2.0" />
<PackageReference Include="RazorLight" Version="2.3.1" />
<PackageReference Include="Refit.HttpClientFactory" Version="8.0.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.5.6" />
</ItemGroup>
@@ -20,5 +23,4 @@
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,30 @@
using HomeApi.Models.Response;
using Refit;
namespace HomeApi.Integration.Client;
public interface IResRobotClient
{
[Get("/v2.1/departureBoard")]
Task<TrafikLabsApiResponse> GetDepartureBoardAsync(
[AliasAs("accessId")] string accessId,
[AliasAs("id")] string stopId,
[AliasAs("direction")] string direction = null,
[AliasAs("date")] string date = null, // Format: YYYY-MM-DD
[AliasAs("time")] string time = null, // Format: HH:MM
[AliasAs("duration")] int? duration = null,
[AliasAs("maxJourneys")] int? maxJourneys = null,
[AliasAs("operators")] string operators = null, // Example: "275,287"
[AliasAs("products")] int? products = null,
[AliasAs("passlist")] int? passlist = 0,
[AliasAs("lang")] string language = "sv",
[AliasAs("format")] string format = "json"
);
[Get("/v2.1/location.name")]
Task<LocationNameResponse> GetLocationsByNameAsync(
[AliasAs("input")] string input,
[AliasAs("format")] string format = "json",
[AliasAs("accessId")] string accessId = "YOUR_API_KEY"
);
}

View File

@@ -0,0 +1,46 @@
using HomeApi.Extensions;
using HomeApi.Integration.Client;
using HomeApi.Models;
using HomeApi.Models.Configuration;
using Microsoft.Extensions.Options;
namespace HomeApi.Integration;
public interface IDepartureBoardService
{
Task<List<TimeTable>?> GetDepartureBoard();
}
public class DepartureBoardService(IResRobotClient departureBoardApi, IOptions<ApiConfiguration> options) : IDepartureBoardService
{
private readonly ApiConfiguration _apiConfig = options.Value;
public async Task<List<TimeTable>?> GetDepartureBoard()
{
var locationResponse = await departureBoardApi.GetLocationsByNameAsync(
input: _apiConfig.DefaultStation,
format: "json",
accessId: _apiConfig.Keys.ResRobot
);
var id = locationResponse.StopLocationOrCoordLocation.FirstOrDefault()?.StopLocation?.ExtId;
if (id == null)
return null;
var result = await departureBoardApi.GetDepartureBoardAsync(
accessId: _apiConfig.Keys.ResRobot,
stopId: id,
direction: null,
date: DateTime.Now.ToString("yyyy-MM-dd"),
time: DateTime.Now.ToString("HH:mm"),
duration: 60,
maxJourneys: 10,
passlist: 1,
language: "sv",
format: "json"
);
return result.ToContract();
}
}

View File

@@ -5,6 +5,7 @@ public class ApiConfiguration
public Keys Keys { get; set; } = new();
public BaseUrls BaseUrls { get; set; } = new();
public string DefaultCity { get; set; } = "Vega stockholms lan";
public string DefaultStation { get; set; } = "Vega station";
}
public class BaseUrls
@@ -12,6 +13,7 @@ public class BaseUrls
public string Weather { get; set; } = string.Empty;
public string Nominatim { get; set; } = string.Empty;
public string Aurora { get; set; } = string.Empty;
public string ResRobot { get; set; } = string.Empty;
}
public class Keys
@@ -19,4 +21,5 @@ public class Keys
public string Weather { get; set; } = string.Empty;
public string Nominatim { get; set; } = string.Empty;
public string Aurora { get; set; } = string.Empty;
public string ResRobot { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,7 @@
namespace HomeApi.Models;
public class Image
{
public WeatherInformation Weather { get; set; }
public List<TimeTable> TimeTable { get; set; }
}

View File

@@ -0,0 +1,88 @@
using System.Text.Json.Serialization;
public class LocationNameResponse
{
[JsonPropertyName("stopLocationOrCoordLocation")]
public List<StopLocationOrCoordLocation> StopLocationOrCoordLocation { get; set; }
[JsonPropertyName("TechnicalMessages")]
public TechnicalMessages TechnicalMessages { get; set; }
[JsonPropertyName("serverVersion")]
public string ServerVersion { get; set; }
[JsonPropertyName("dialectVersion")]
public string DialectVersion { get; set; }
[JsonPropertyName("requestId")]
public string RequestId { get; set; }
}
public class StopLocationOrCoordLocation
{
[JsonPropertyName("StopLocation")]
public StopLocation StopLocation { get; set; }
}
public class StopLocation
{
[JsonPropertyName("productAtStop")]
public List<ProductAtStop> ProductAtStop { get; set; }
[JsonPropertyName("timezoneOffset")]
public int TimezoneOffset { get; set; }
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("extId")]
public string ExtId { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("lon")]
public double Lon { get; set; }
[JsonPropertyName("lat")]
public double Lat { get; set; }
[JsonPropertyName("weight")]
public int Weight { get; set; }
[JsonPropertyName("products")]
public int Products { get; set; }
[JsonPropertyName("minimumChangeDuration")]
public string MinimumChangeDuration { get; set; }
}
public class ProductAtStop
{
[JsonPropertyName("icon")]
public Icon Icon { get; set; }
[JsonPropertyName("cls")]
public string Cls { get; set; }
}
public class Icon
{
[JsonPropertyName("res")]
public string Res { get; set; }
}
public class TechnicalMessages
{
[JsonPropertyName("TechnicalMessage")]
public List<TechnicalMessage> TechnicalMessage { get; set; }
}
public class TechnicalMessage
{
[JsonPropertyName("value")]
public string Value { get; set; }
[JsonPropertyName("key")]
public string Key { get; set; }
}

View File

@@ -0,0 +1,84 @@
namespace HomeApi.Models.Response;
public class TrafikLabsApiResponse
{
public List<Departure> Departure { get; set; }
}
public class Departure
{
public JourneyDetailRef JourneyDetailRef { get; set; }
public string JourneyStatus { get; set; }
public ProductDetail ProductAtStop { get; set; }
public List<ProductDetail> Product { get; set; }
public Notes Notes { get; set; }
public string Name { get; set; }
public string Type { get; set; }
public string Stop { get; set; }
public string Stopid { get; set; }
public string StopExtId { get; set; }
public double Lon { get; set; }
public double Lat { get; set; }
public string Time { get; set; }
public string Date { get; set; }
public bool Reachable { get; set; }
public string Direction { get; set; }
public string DirectionFlag { get; set; }
}
public class JourneyDetailRef
{
public string Ref { get; set; }
}
public class ProductDetail
{
public Icon Icon { get; set; }
public OperatorInfo OperatorInfo { get; set; }
public string Name { get; set; }
public string InternalName { get; set; }
public string DisplayNumber { get; set; }
public string Num { get; set; }
public string Line { get; set; }
public string LineId { get; set; }
public string CatOut { get; set; }
public string CatIn { get; set; }
public string CatCode { get; set; }
public string Cls { get; set; }
public string CatOutS { get; set; }
public string CatOutL { get; set; }
public string OperatorCode { get; set; }
public string Operator { get; set; }
public string Admin { get; set; }
public string MatchId { get; set; }
public int? RouteIdxFrom { get; set; }
public int? RouteIdxTo { get; set; }
}
public class Icon
{
public string Res { get; set; }
}
public class OperatorInfo
{
public string Name { get; set; }
public string NameS { get; set; }
public string NameN { get; set; }
public string NameL { get; set; }
public string Id { get; set; }
}
public class Notes
{
public List<Note> Note { get; set; }
}
public class Note
{
public string Value { get; set; }
public string Key { get; set; }
public string Type { get; set; }
public int RouteIdxFrom { get; set; }
public int RouteIdxTo { get; set; }
public string TxtN { get; set; }
}

View File

@@ -0,0 +1,14 @@
namespace HomeApi.Models;
public class TimeTable
{
public string LineNumber { get; set; } // e.g. "43", "832"
public string LineName { get; set; } // e.g. "Länstrafik - Tåg 43"
public string TransportType { get; set; } // e.g. "Tåg", "Buss"
public string Operator { get; set; } // e.g. "SL"
public string StopName { get; set; } // e.g. "Vega station (Haninge kn)"
public string DepartureTime { get; set; } // e.g. 2025-07-15 01:03
public string Direction { get; set; } // e.g. "Farsta Strand station"
public string JourneyDetailRef { get; set; } // e.g. "1|39437|0|1|15072025"
public List<string> Notes { get; set; } // e.g. "Pendeltåg", "Endast 2 klass"
}

View File

@@ -28,7 +28,11 @@ public static class RegisterIntegration
services.AddRefitClient<IWeatherClient>()
.ConfigureBaseAddress(apiConfiguration => apiConfiguration.BaseUrls.Weather);
services.AddRefitClient<IResRobotClient>()
.ConfigureBaseAddress(apiConfiguration => apiConfiguration.BaseUrls.ResRobot);
services.AddScoped<IDepartureBoardService, DepartureBoardService>();
services.AddScoped<IGeocodingService, GeocodingService>();
services.AddScoped<IAuroraService, AuroraService>();
services.AddScoped<IWeatherService, WeatherService>();

View File

@@ -7,14 +7,16 @@
},
"ApiConfiguration": {
"Keys": {
"Weather": "KEY",
"SL": ""
"Weather": "NOT COMMITED",
"ResRobot": "NOT COMMITED"
},
"BaseUrls": {
"Nominatim": "https://nominatim.openstreetmap.org",
"Aurora": "http://api.auroras.live",
"Weather": "https://api.weatherapi.com/v1"
"Weather": "https://api.weatherapi.com/v1",
"ResRobot": "https://api.resrobot.se"
},
"DefaultCity": "Vega stockholms lan"
"DefaultCity": "Vega stockholms lan",
"DefaultStation": "Vega Station"
}
}

View File

@@ -7,15 +7,17 @@
},
"ApiConfiguration": {
"Keys": {
"Weather": "KEY",
"SL": ""
"Weather": "NOT COMMITED",
"ResRobot": "NOT COMMITED"
},
"BaseUrls": {
"Nominatim": "https://nominatim.openstreetmap.org",
"Aurora": "http://api.auroras.live",
"Weather": "https://api.weatherapi.com/v1"
"Weather": "https://api.weatherapi.com/v1",
"ResRobot": "https://api.resrobot.se"
},
"DefaultCity": "Vega stockholms lan"
"DefaultCity": "Vega stockholms lan",
"DefaultStation": "Vega Station"
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,212 @@
@model HomeApi.Models.Image
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Weather Dashboard</title>
<style>
html, body {
width: 800px;
height: 480px;
margin: 0;
padding: 0;
background: #f0f4f8;
color: #333;
font-family: Arial, sans-serif;
box-sizing: border-box;
overflow: hidden;
font-size: 13px;
}
.section {
margin-bottom: 6px;
padding: 4px;
}
.card {
background: white;
border-radius: 8px;
padding: 6px;
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
flex: 1;
font-size: 12px;
}
.scroll {
max-height: 140px;
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
th, td {
padding: 2px 4px;
border: 1px solid #e0e0e0;
text-align: left;
}
th {
background: #e8eef3;
font-weight: bold;
}
img.icon {
width: 24px;
height: 24px;
}
</style>
</head>
<body>
<div class="section">
<h2 style="font-size:16px;">@Model.Weather.CityName</h2>
<div class="flex-row">
Current Weather
<table>
<thead>
<tr>
<th>Temp</th>
<th>Feels</th>
<th>Clouds</th>
<th>Wind</th>
<th>Gusts</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
<tr>
<td>@Model.Weather.Current.Temperature °C</td>
<td>@Model.Weather.Current.Feelslike °C</td>
<td>@Model.Weather.Current.Cloud%</td>
<td>@Model.Weather.Current.WindPerMeterSecond m/s (@Model.Weather.Current.WindDirection)</td>
<td>@Model.Weather.Current.WindGustPerMeterSecond m/s</td>
<td>@Model.Weather.Current.LastUpdated</td>
</tr>
</tbody>
</table>
<br/>
Current Air Quality
<table>
<thead>
<tr>
<th>CO</th>
<th>NO₂</th>
<th>O₃</th>
<th>SO₂</th>
<th>PM2.5</th>
<th>PM10</th>
<th>EPA</th>
<th>DEFRA</th>
</tr>
</thead>
<tbody>
<tr>
<td>@Model.Weather.Current.AirQuality?.Co</td>
<td>@Model.Weather.Current.AirQuality?.No2</td>
<td>@Model.Weather.Current.AirQuality?.O3</td>
<td>@Model.Weather.Current.AirQuality?.So2</td>
<td>@Model.Weather.Current.AirQuality?.Pm2_5</td>
<td>@Model.Weather.Current.AirQuality?.Pm10</td>
<td>@Model.Weather.Current.AirQuality?.Us_Epa_Index</td>
<td>@Model.Weather.Current.AirQuality?.Gb_Defra_Index</td>
</tr>
</tbody>
</table>
<br/>
Aurora Probability
<table>
<thead>
<tr>
<th>Probability</th>
<th>Color</th>
<th>Highest</th>
<th>Location</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr>
<td>@Model.Weather.Current.AuroraProbability?.Value%</td>
<td>@Model.Weather.Current.AuroraProbability?.Colour</td>
<td>@Model.Weather.Current.AuroraProbability?.HighestProbability?.Value%</td>
<td>(@Model.Weather.Current.AuroraProbability?.HighestProbability?.Lat, @Model.Weather.Current.AuroraProbability?.HighestProbability?.Long)</td>
<td>@Model.Weather.Current.AuroraProbability?.HighestProbability?.Date.ToShortDateString()</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="section scroll">
<h3 style="font-size:14px;">Forecast</h3>
<table>
<thead>
<tr>
<th>Date</th>
<th>Icon</th>
<th>Min</th>
<th>Max</th>
<th>Day</th>
<th>Feels</th>
<th>Rain</th>
<th>Snow</th>
<th>Sunrise</th>
<th>Sunset</th>
<th>Moonrise</th>
<th>Moonset</th>
<th>Moon</th>
</tr>
</thead>
<tbody>
@foreach (var f in Model.Weather.Forecast)
{
<tr>
<td>@f.Date</td>
<td><img class="icon" src="@f.DayIcon" alt="Icon" /></td>
<td>@f.MinTempC°C</td>
<td>@f.MaxTempC°C</td>
<td>@f.Day?.ConditionText</td>
<td>@f.Day?.AvgFeelslikeC°C</td>
<td>@f.Day?.TotalChanceOfRain%</td>
<td>@f.Day?.TotalChanceOfSnow%</td>
<td>@f.Astro.Sunrise</td>
<td>@f.Astro.Sunset</td>
<td>@f.Astro.Moonrise</td>
<td>@f.Astro.Moonset</td>
<td>@f.Astro.Moon_Phase (@f.Astro.Moon_Illumination%)</td>
</tr>
}
</tbody>
</table>
</div>
<div class="section scroll">
<h3 style="font-size:14px;">Public Transport Departures</h3>
<table>
<thead>
<tr>
<th>Type</th>
<th>Line</th>
<th>Name</th>
<th>Operator</th>
<th>Stop</th>
<th>Departure</th>
<th>Direction</th>
</tr>
</thead>
<tbody>
@foreach (var t in Model.TimeTable)
{
<tr>
<td>@t.TransportType</td>
<td>@t.LineNumber</td>
<td>@t.LineName</td>
<td>@t.Operator</td>
<td>@t.StopName</td>
<td>@t.DepartureTime</td>
<td>@t.Direction</td>
</tr>
}
</tbody>
</table>
</div>
</body>
</html>