From 480a38baace152357e6bb00c0de96c98312447b1 Mon Sep 17 00:00:00 2001 From: Myx Date: Tue, 15 Jul 2025 02:42:16 +0200 Subject: [PATCH] Add image generation --- HomeApi/Controllers/HomeController.cs | 15 +- HomeApi/Extensions/ContractExtensions.cs | 18 ++ HomeApi/Handlers/DepartureBoard.cs | 25 +++ HomeApi/Handlers/ImageGeneration.cs | 75 +++++++ .../Handlers/{GetWeather.cs => Weather.cs} | 2 +- HomeApi/HomeApi.csproj | 4 +- HomeApi/Integration/Client/ResRobotClient.cs | 30 +++ HomeApi/Integration/DepartureBoardService.cs | 46 ++++ .../Models/Configuration/ApiConfiguration.cs | 3 + HomeApi/Models/ImageGeneration.cs | 7 + .../Models/Response/LocationNameResponse.cs | 88 ++++++++ .../Models/Response/TrafikLabsApiResponse.cs | 84 +++++++ HomeApi/Models/TimeTable.cs | 14 ++ HomeApi/Registration/RegisterIntegration.cs | 4 + HomeApi/appsettings.Development.json | 10 +- HomeApi/appsettings.json | 10 +- HomeApi/wwwroot/index.cshtml | 212 ++++++++++++++++++ 17 files changed, 635 insertions(+), 12 deletions(-) create mode 100644 HomeApi/Handlers/DepartureBoard.cs create mode 100644 HomeApi/Handlers/ImageGeneration.cs rename HomeApi/Handlers/{GetWeather.cs => Weather.cs} (98%) create mode 100644 HomeApi/Integration/Client/ResRobotClient.cs create mode 100644 HomeApi/Integration/DepartureBoardService.cs create mode 100644 HomeApi/Models/ImageGeneration.cs create mode 100644 HomeApi/Models/Response/LocationNameResponse.cs create mode 100644 HomeApi/Models/Response/TrafikLabsApiResponse.cs create mode 100644 HomeApi/Models/TimeTable.cs create mode 100644 HomeApi/wwwroot/index.cshtml diff --git a/HomeApi/Controllers/HomeController.cs b/HomeApi/Controllers/HomeController.cs index a561a39..b9ba04a 100644 --- a/HomeApi/Controllers/HomeController.cs +++ b/HomeApi/Controllers/HomeController.cs @@ -12,7 +12,18 @@ public class HomeController(IMediator mediator) : ControllerBase [HttpGet(Name = "GetHome")] public async Task> 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 GetImage() + { + return File(await mediator.Send(new ImageGeneration.Command()), "image/png"); + } + + [HttpGet("departureboard")] + public async Task>> GetDepartureBoard() + { + return Ok(await mediator.Send(new DepartureBoard.Command())); } } \ No newline at end of file diff --git a/HomeApi/Extensions/ContractExtensions.cs b/HomeApi/Extensions/ContractExtensions.cs index 8937159..7d75051 100644 --- a/HomeApi/Extensions/ContractExtensions.cs +++ b/HomeApi/Extensions/ContractExtensions.cs @@ -126,4 +126,22 @@ public static class ContractExtensions }; } + public static List? 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(); + } } \ No newline at end of file diff --git a/HomeApi/Handlers/DepartureBoard.cs b/HomeApi/Handlers/DepartureBoard.cs new file mode 100644 index 0000000..8bf80dd --- /dev/null +++ b/HomeApi/Handlers/DepartureBoard.cs @@ -0,0 +1,25 @@ +using HomeApi.Integration; +using HomeApi.Models; +using MediatR; + +namespace HomeApi.Handlers; + +public static class DepartureBoard +{ + public record Command : IRequest>; + + public class Handler : IRequestHandler> + { + private readonly IDepartureBoardService _departureBoardService; + + public Handler(IDepartureBoardService departureBoardService) + { + _departureBoardService = departureBoardService; + } + + public async Task> Handle(Command request, CancellationToken cancellationToken) + { + return await _departureBoardService.GetDepartureBoard() ?? new List(); + } + } +} \ No newline at end of file diff --git a/HomeApi/Handlers/ImageGeneration.cs b/HomeApi/Handlers/ImageGeneration.cs new file mode 100644 index 0000000..ecf8c65 --- /dev/null +++ b/HomeApi/Handlers/ImageGeneration.cs @@ -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; + + public class Handler : IRequestHandler + { + private readonly ILogger _logger; + private readonly IWebHostEnvironment _env; + private readonly IMediator _mediator; + public Handler( + IOptions apiConfiguration, + ILogger logger, IWebHostEnvironment env, IMediator mediator) + { + _logger = logger; + _env = env; + _mediator = mediator; + } + + public async Task 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 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; + } + } +} \ No newline at end of file diff --git a/HomeApi/Handlers/GetWeather.cs b/HomeApi/Handlers/Weather.cs similarity index 98% rename from HomeApi/Handlers/GetWeather.cs rename to HomeApi/Handlers/Weather.cs index 2deb672..07ab89d 100644 --- a/HomeApi/Handlers/GetWeather.cs +++ b/HomeApi/Handlers/Weather.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Options; namespace HomeApi.Handlers; -public static class GetWeather +public static class Weather { public record Command : IRequest; diff --git a/HomeApi/HomeApi.csproj b/HomeApi/HomeApi.csproj index cfae6fe..a117e92 100644 --- a/HomeApi/HomeApi.csproj +++ b/HomeApi/HomeApi.csproj @@ -5,12 +5,15 @@ enable enable Linux + true + + @@ -20,5 +23,4 @@ .dockerignore - diff --git a/HomeApi/Integration/Client/ResRobotClient.cs b/HomeApi/Integration/Client/ResRobotClient.cs new file mode 100644 index 0000000..9ed0f33 --- /dev/null +++ b/HomeApi/Integration/Client/ResRobotClient.cs @@ -0,0 +1,30 @@ +using HomeApi.Models.Response; +using Refit; + +namespace HomeApi.Integration.Client; + +public interface IResRobotClient +{ + [Get("/v2.1/departureBoard")] + Task 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 GetLocationsByNameAsync( + [AliasAs("input")] string input, + [AliasAs("format")] string format = "json", + [AliasAs("accessId")] string accessId = "YOUR_API_KEY" + ); +} \ No newline at end of file diff --git a/HomeApi/Integration/DepartureBoardService.cs b/HomeApi/Integration/DepartureBoardService.cs new file mode 100644 index 0000000..ca7a424 --- /dev/null +++ b/HomeApi/Integration/DepartureBoardService.cs @@ -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?> GetDepartureBoard(); +} + +public class DepartureBoardService(IResRobotClient departureBoardApi, IOptions options) : IDepartureBoardService +{ + private readonly ApiConfiguration _apiConfig = options.Value; + + public async Task?> 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(); + } +} \ No newline at end of file diff --git a/HomeApi/Models/Configuration/ApiConfiguration.cs b/HomeApi/Models/Configuration/ApiConfiguration.cs index 61f3bba..e21eb70 100644 --- a/HomeApi/Models/Configuration/ApiConfiguration.cs +++ b/HomeApi/Models/Configuration/ApiConfiguration.cs @@ -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; } \ No newline at end of file diff --git a/HomeApi/Models/ImageGeneration.cs b/HomeApi/Models/ImageGeneration.cs new file mode 100644 index 0000000..e5b1350 --- /dev/null +++ b/HomeApi/Models/ImageGeneration.cs @@ -0,0 +1,7 @@ +namespace HomeApi.Models; + +public class Image +{ + public WeatherInformation Weather { get; set; } + public List TimeTable { get; set; } +} \ No newline at end of file diff --git a/HomeApi/Models/Response/LocationNameResponse.cs b/HomeApi/Models/Response/LocationNameResponse.cs new file mode 100644 index 0000000..04e6d90 --- /dev/null +++ b/HomeApi/Models/Response/LocationNameResponse.cs @@ -0,0 +1,88 @@ +using System.Text.Json.Serialization; + +public class LocationNameResponse +{ + [JsonPropertyName("stopLocationOrCoordLocation")] + public List 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 { 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 { get; set; } +} + +public class TechnicalMessage +{ + [JsonPropertyName("value")] + public string Value { get; set; } + + [JsonPropertyName("key")] + public string Key { get; set; } +} diff --git a/HomeApi/Models/Response/TrafikLabsApiResponse.cs b/HomeApi/Models/Response/TrafikLabsApiResponse.cs new file mode 100644 index 0000000..6bcc814 --- /dev/null +++ b/HomeApi/Models/Response/TrafikLabsApiResponse.cs @@ -0,0 +1,84 @@ +namespace HomeApi.Models.Response; +public class TrafikLabsApiResponse +{ + public List Departure { get; set; } +} + +public class Departure +{ + public JourneyDetailRef JourneyDetailRef { get; set; } + public string JourneyStatus { get; set; } + public ProductDetail ProductAtStop { get; set; } + public List 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 { 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; } +} diff --git a/HomeApi/Models/TimeTable.cs b/HomeApi/Models/TimeTable.cs new file mode 100644 index 0000000..007e29d --- /dev/null +++ b/HomeApi/Models/TimeTable.cs @@ -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 Notes { get; set; } // e.g. "Pendeltåg", "Endast 2 klass" +} \ No newline at end of file diff --git a/HomeApi/Registration/RegisterIntegration.cs b/HomeApi/Registration/RegisterIntegration.cs index 78952db..b7799ef 100644 --- a/HomeApi/Registration/RegisterIntegration.cs +++ b/HomeApi/Registration/RegisterIntegration.cs @@ -28,7 +28,11 @@ public static class RegisterIntegration services.AddRefitClient() .ConfigureBaseAddress(apiConfiguration => apiConfiguration.BaseUrls.Weather); + + services.AddRefitClient() + .ConfigureBaseAddress(apiConfiguration => apiConfiguration.BaseUrls.ResRobot); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/HomeApi/appsettings.Development.json b/HomeApi/appsettings.Development.json index 9e738ac..0d65bc5 100644 --- a/HomeApi/appsettings.Development.json +++ b/HomeApi/appsettings.Development.json @@ -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" } } diff --git a/HomeApi/appsettings.json b/HomeApi/appsettings.json index a419db2..0f339c0 100644 --- a/HomeApi/appsettings.json +++ b/HomeApi/appsettings.json @@ -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": "*" } diff --git a/HomeApi/wwwroot/index.cshtml b/HomeApi/wwwroot/index.cshtml new file mode 100644 index 0000000..f99bd7d --- /dev/null +++ b/HomeApi/wwwroot/index.cshtml @@ -0,0 +1,212 @@ +@model HomeApi.Models.Image + + + + + + Weather Dashboard + + + +
+

@Model.Weather.CityName

+
+ Current Weather + + + + + + + + + + + + + + + + + + + + + +
TempFeelsCloudsWindGustsUpdated
@Model.Weather.Current.Temperature °C@Model.Weather.Current.Feelslike °C@Model.Weather.Current.Cloud%@Model.Weather.Current.WindPerMeterSecond m/s (@Model.Weather.Current.WindDirection)@Model.Weather.Current.WindGustPerMeterSecond m/s@Model.Weather.Current.LastUpdated
+
+ Current Air Quality + + + + + + + + + + + + + + + + + + + + + + + + + +
CONO₂O₃SO₂PM2.5PM10EPADEFRA
@Model.Weather.Current.AirQuality?.Co@Model.Weather.Current.AirQuality?.No2@Model.Weather.Current.AirQuality?.O3@Model.Weather.Current.AirQuality?.So2@Model.Weather.Current.AirQuality?.Pm2_5@Model.Weather.Current.AirQuality?.Pm10@Model.Weather.Current.AirQuality?.Us_Epa_Index@Model.Weather.Current.AirQuality?.Gb_Defra_Index
+
+ Aurora Probability + + + + + + + + + + + + + + + + + + + +
ProbabilityColorHighestLocationDate
@Model.Weather.Current.AuroraProbability?.Value%@Model.Weather.Current.AuroraProbability?.Colour@Model.Weather.Current.AuroraProbability?.HighestProbability?.Value%(@Model.Weather.Current.AuroraProbability?.HighestProbability?.Lat, @Model.Weather.Current.AuroraProbability?.HighestProbability?.Long)@Model.Weather.Current.AuroraProbability?.HighestProbability?.Date.ToShortDateString()
+ +
+
+ +
+

Forecast

+ + + + + + + + + + + + + + + + + + + + @foreach (var f in Model.Weather.Forecast) + { + + + + + + + + + + + + + + + + } + +
DateIconMinMaxDayFeelsRainSnowSunriseSunsetMoonriseMoonsetMoon
@f.DateIcon@f.MinTempC°C@f.MaxTempC°C@f.Day?.ConditionText@f.Day?.AvgFeelslikeC°C@f.Day?.TotalChanceOfRain%@f.Day?.TotalChanceOfSnow%@f.Astro.Sunrise@f.Astro.Sunset@f.Astro.Moonrise@f.Astro.Moonset@f.Astro.Moon_Phase (@f.Astro.Moon_Illumination%)
+
+ +
+

Public Transport Departures

+ + + + + + + + + + + + + + @foreach (var t in Model.TimeTable) + { + + + + + + + + + + } + +
TypeLineNameOperatorStopDepartureDirection
@t.TransportType@t.LineNumber@t.LineName@t.Operator@t.StopName@t.DepartureTime@t.Direction
+
+ + \ No newline at end of file