diff --git a/.gitignore b/.gitignore index 5e57f18..2db5ecd 100644 --- a/.gitignore +++ b/.gitignore @@ -482,3 +482,4 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp +appsettings.Local.json \ No newline at end of file diff --git a/DisplayUtil.csproj b/DisplayUtil.csproj index e4dfa63..cf95fcd 100644 --- a/DisplayUtil.csproj +++ b/DisplayUtil.csproj @@ -9,6 +9,8 @@ + + diff --git a/Layouting/IconElement.cs b/Layouting/IconElement.cs index 5e67f86..a4232b6 100644 --- a/Layouting/IconElement.cs +++ b/Layouting/IconElement.cs @@ -7,15 +7,15 @@ namespace DisplayUtil.Layouting; /// An FontAwesome Icon Element /// /// Name of the Icon -/// Width of the Icon +/// Width of the Icon /// The Icon Drawer -public class IconElement(string iconName, int width, FaIconDrawer iconDrawer) : Element +public class IconElement(string iconName, int height, FaIconDrawer iconDrawer) : Element { public override void Draw(DrawContext drawContext) { iconDrawer.DrawIcon( iconName, - width, + height, drawContext.StartPoint, drawContext.Canvas ); @@ -23,7 +23,7 @@ public override void Draw(DrawContext drawContext) protected override SKSize CalculateSize(DrawContext drawContext) { - var size = iconDrawer.GetSize(iconName, width); + var size = iconDrawer.GetSize(iconName, height); return size.GetValueOrDefault(); } } diff --git a/Layouting/TextElement.cs b/Layouting/TextElement.cs index cf99bc5..b217912 100644 --- a/Layouting/TextElement.cs +++ b/Layouting/TextElement.cs @@ -12,7 +12,7 @@ public class TextElement(string content, SKPaint paint) : Element public override void Draw(DrawContext drawContext) { var point = drawContext.StartPoint; - point.Y += paint.TextSize; + point.Y -= paint.FontMetrics.Ascent; drawContext.Canvas.DrawText(content, point, paint); } diff --git a/Program.cs b/Program.cs index 20f3298..a70a384 100644 --- a/Program.cs +++ b/Program.cs @@ -5,19 +5,26 @@ using DisplayUtil.Utils; var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", true); // Add services to the container. // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.AddHassSupport(); builder.Services.AddSingleton(FontProvider.Create()) - .AddSingleton() - .AddSingleton(); + .AddSingleton(); + +builder.Services.AddScoped() + .AddScoped(); + builder.Services.AddTransient(); + builder.Services.AddScreenProvider(o => o - .Add("test") - .Add("layout") + .AddSingleton("test") + .AddSingleton("layout") + .AddSingleton("testFont") .AddScribanFiles() ); diff --git a/Resources/fonts/Roboto-Medium.ttf b/Resources/fonts/Roboto-Medium.ttf new file mode 100644 index 0000000..ac0f908 Binary files /dev/null and b/Resources/fonts/Roboto-Medium.ttf differ diff --git a/Resources/screens/main.sbntxt b/Resources/screens/main.sbntxt index a76717c..e3ec365 100644 --- a/Resources/screens/main.sbntxt +++ b/Resources/screens/main.sbntxt @@ -2,27 +2,38 @@ ret date.to_string date.now sFormat end }} +{{ func icon_data(sIconName, sText) }} + + + + +{{ end }} + - - + + + + + + + {{ icon_data "calendar" (now_format "%A")}} + + + + + + 2 + + + - - - - - - - - + {{ icon_data "couch" (hass_get_state "sensor.ble_temperature_dc1e00000998" | string.slice 0 5)}} + {{ icon_data "cloud-sun" (hass_get_state "sensor.hue_outdoor_motion_sensor_1_temperature" | string.slice 0 5)}} - - 2 - - - + \ No newline at end of file diff --git a/Screens/IScreenProviderSource.cs b/Screens/IScreenProviderSource.cs new file mode 100644 index 0000000..d23637f --- /dev/null +++ b/Screens/IScreenProviderSource.cs @@ -0,0 +1,15 @@ +namespace DisplayUtil.Scenes; + +/// +/// Provides by Ids +/// +public interface IScreenProviderSource +{ + /// + /// Returns the if this source contains it + /// + /// Id + /// Screen Provider If found + public IScreenProvider? GetScreenProvider(string id); + +} \ No newline at end of file diff --git a/Screens/ScreenRepoBuilder.cs b/Screens/ScreenRepoBuilder.cs index 42870e9..87a9c57 100644 --- a/Screens/ScreenRepoBuilder.cs +++ b/Screens/ScreenRepoBuilder.cs @@ -4,37 +4,29 @@ namespace DisplayUtil.Scenes; public class ScreenRepoBuilder( - Dictionary> screenProviders, + Dictionary staticScreenProviderTypes, IServiceCollection services ) { - public ScreenRepoBuilder Add(string providerId) + public ScreenRepoBuilder AddSingleton(string providerId) where TType : class, IScreenProvider { + staticScreenProviderTypes.Add(providerId, typeof(TType)); services.AddSingleton(); - screenProviders.Add( - providerId, - e => (IScreenProvider)e.GetRequiredService(typeof(TType)) - ); return this; } - public ScreenRepoBuilder AddScribanFiles(string? path = null) + public ScreenRepoBuilder AddScoped(string providerId) + where TType : class, IScreenProvider { - path ??= @"./Resources/screens"; - - var files = Directory.EnumerateFiles(path, "*.sbntxt") - .Select(p => new { Path = p, Name = Path.GetFileNameWithoutExtension(p) }) - .Select(p => new KeyValuePair>( - p.Name, - e => ActivatorUtilities.CreateInstance(e, [p.Path]) - )); - - foreach (var (k, v) in files) - { - screenProviders.Add(k, v); - } + staticScreenProviderTypes.Add(providerId, typeof(TType)); + services.AddScoped(); + return this; + } + public ScreenRepoBuilder AddScribanFiles(string? path = null) + { + services.AddScoped(); return this; } } @@ -44,18 +36,20 @@ public static class ScreenRepoBuilderExtension public static IServiceCollection AddScreenProvider( this IServiceCollection services, Action action) { - var dictionary = new Dictionary>(); + var dictionary = new Dictionary(); var builder = new ScreenRepoBuilder(dictionary, services); action(builder); - services.AddSingleton(o => + // Add static + if (dictionary.Count != 0) { - var types = dictionary - .ToDictionary(k => k.Key, v => v.Value(o)) - .ToFrozenDictionary(); + services.AddSingleton(s => new StaticScreenProviderSource( + s, dictionary.ToFrozenDictionary() + )); + } + + services.AddScoped(); - return new ScreenRepository(types); - }); return services; } diff --git a/Screens/ScreenRepository.cs b/Screens/ScreenRepository.cs index a8fe04f..7f7c6b0 100644 --- a/Screens/ScreenRepository.cs +++ b/Screens/ScreenRepository.cs @@ -3,16 +3,30 @@ namespace DisplayUtil.Scenes; public class ScreenRepository( - IReadOnlyDictionary screenProviders + IEnumerable screenProviderSources ) { - public Task GetImageAsync(string screenProviderId) + /// + /// Search the with it's Id + /// + /// Id of the screen provider + /// ScreenProvider + /// Not found + public IScreenProvider GetScreenProvider(string screenProviderId) { - if (!screenProviders.TryGetValue(screenProviderId, out var storedScreenProvider)) + foreach (var screenProviderSource in screenProviderSources) { - throw new Exception($"ScreenProvider with Id {screenProviderId} not found"); + var screenProvider = screenProviderSource + .GetScreenProvider(screenProviderId); + + if (screenProvider is not null) return screenProvider; } - return storedScreenProvider.GetImageAsync(); + throw new Exception($"ScreenProvider with Id {screenProviderId} not found"); + } + + public Task GetImageAsync(string screenProviderId) + { + return GetScreenProvider(screenProviderId).GetImageAsync(); } } \ No newline at end of file diff --git a/Screens/StaticScreenProviderSource.cs b/Screens/StaticScreenProviderSource.cs new file mode 100644 index 0000000..6636949 --- /dev/null +++ b/Screens/StaticScreenProviderSource.cs @@ -0,0 +1,14 @@ +namespace DisplayUtil.Scenes; + +internal class StaticScreenProviderSource( + IServiceProvider serviceProvider, + IReadOnlyDictionary typeList +) : IScreenProviderSource +{ + public IScreenProvider? GetScreenProvider(string id) + { + if (!typeList.TryGetValue(id, out var type)) return null; + + return (IScreenProvider)serviceProvider.GetRequiredService(type); + } +} \ No newline at end of file diff --git a/Serializing/Models/Icon.cs b/Serializing/Models/Icon.cs index 16f641d..d018c2a 100644 --- a/Serializing/Models/Icon.cs +++ b/Serializing/Models/Icon.cs @@ -13,10 +13,10 @@ public class Icon : IXmlModel public string IconName = null!; [XmlAttribute] - public int Width = 20; + public int Height = 20; public override Element AsElement(FaIconDrawer iconDrawer, FontProvider fontProvider) { - return new IconElement(IconName, Width, iconDrawer); + return new IconElement(IconName, Height, iconDrawer); } } \ No newline at end of file diff --git a/Serializing/Models/Text.cs b/Serializing/Models/Text.cs index 5be56a4..5d99aab 100644 --- a/Serializing/Models/Text.cs +++ b/Serializing/Models/Text.cs @@ -21,13 +21,12 @@ public class Text : IXmlModel public override Element AsElement(FaIconDrawer iconDrawer, FontProvider fontProvider) { - var font = fontProvider.GetFont(Font ?? "ProductSansRegular"); + var font = fontProvider.GetFont(Font ?? "Roboto-Medium"); var paint = new SKPaint { IsAntialias = true, TextSize = Size, - TextAlign = SKTextAlign.Left, Color = SKColors.Black, Style = SKPaintStyle.Fill, Typeface = font diff --git a/Template/ScribanScreenProvider.cs b/Template/ScribanScreenProvider.cs index e0bcc86..162351f 100644 --- a/Template/ScribanScreenProvider.cs +++ b/Template/ScribanScreenProvider.cs @@ -6,7 +6,30 @@ namespace DisplayUtil.Template; -public class ScribanScreenProvider( +internal class ScibanScreenProviderSource(IServiceProvider serviceProvider) + : IScreenProviderSource +{ + + private const string TemplatePath = "./Resources/screens"; + + private static readonly ObjectFactory + factory = ActivatorUtilities.CreateFactory([ + typeof(string) + ]); + + public IScreenProvider? GetScreenProvider(string id) + { + var path = Path.GetFullPath( + Path.Combine(TemplatePath, $"{id}.sbntxt") + ); + + if (!File.Exists(path)) return null; + + return factory(serviceProvider, [path]); + } +} + +internal class ScribanScreenProvider( TemplateRenderer renderer, XmlLayoutDeserializer layoutDeserializer, string path) diff --git a/Template/TemplateContextProvider.cs b/Template/TemplateContextProvider.cs new file mode 100644 index 0000000..6e888f7 --- /dev/null +++ b/Template/TemplateContextProvider.cs @@ -0,0 +1,61 @@ +using System.Dynamic; +using System.Globalization; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; +using NetDaemon.HassModel; +using Scriban; +using Scriban.Runtime; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace DisplayUtil.Template; + +/// +/// Provides the default template objects. +/// Scoped +/// +public class TemplateContextProvider(IHaContext haContext) +{ + public TemplateContext GetTemplateContext() + { + var scriptObject = new ScriptObject(); + scriptObject.Import("hass_get_state", GetState); + scriptObject.Import("hass_get_attribute", GetAttribute); + + var context = new TemplateContext(); + context.PushCulture(CultureInfo.GetCultureInfo("de-DE")); + context.PushGlobal(scriptObject); + //context.MemberFilter = MemberFilter; + + return context; + } + + private string? GetState(string entityId) + { + var entity = haContext.GetState(entityId); + return entity?.State; + } + + private string? GetAttribute(string entityId, string attribute) + { + var entity = haContext.GetState(entityId); + var attributes = entity.Attributes as Dictionary; + object? value = null; + + if (!attributes?.TryGetValue(attribute, out value) ?? true) + return null; + + return value?.ToString(); + } + + private bool MemberFilter(MemberInfo member) + { + return member switch + { + MethodInfo m => m.IsPublic, + PropertyInfo p => p.IsPubliclyReadable(), + _ => false + }; + } + +} diff --git a/Template/TemplateRenderer.cs b/Template/TemplateRenderer.cs index cd07a83..75b07c4 100644 --- a/Template/TemplateRenderer.cs +++ b/Template/TemplateRenderer.cs @@ -5,17 +5,16 @@ namespace DisplayUtil.Template; /// -/// Responsible to render a Scriban Template +/// Responsible to render a Scriban Template. +/// Scoped /// -public class TemplateRenderer +public class TemplateRenderer(TemplateContextProvider contextProvider) { public async Task RenderToStreamAsync(string content) { - var context = new TemplateContext(); - context.PushCulture(CultureInfo.GetCultureInfo("de-DE")); - var template = Scriban.Template.Parse(content); - var rendered = await template.RenderAsync(context); + var rendered = await template.RenderAsync(contextProvider + .GetTemplateContext()); var memoryStream = new MemoryStream(); memoryStream.Write(Encoding.UTF8.GetBytes(rendered)); diff --git a/TestFontSizeProvider.cs b/TestFontSizeProvider.cs new file mode 100644 index 0000000..003324e --- /dev/null +++ b/TestFontSizeProvider.cs @@ -0,0 +1,32 @@ +using DisplayUtil.Scenes; +using DisplayUtil.Utils; +using SkiaSharp; + +namespace DisplayUtil; + +internal class TestFontSizeProvider(FaIconDrawer iconDrawer) : IScreenProvider +{ + public Task GetImageAsync() + { + var bitmap = new SKBitmap(64, 32); + var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.White); + + var productSans = SKTypeface.FromFile("./Resources/Roboto-Medium.ttf"); + + var paint = new SKPaint + { + IsAntialias = true, + TextSize = 32, + TextAlign = SKTextAlign.Left, + Color = SKColors.Black, + Style = SKPaintStyle.Fill, + Typeface = productSans + }; + canvas.DrawText("A", 0, 32, paint); + + iconDrawer.DrawIcon("couch", 32, new SKPoint(32, 0), canvas); + + return Task.FromResult(bitmap); + } +} \ No newline at end of file diff --git a/Utils/FaIconDrawer.cs b/Utils/FaIconDrawer.cs index cb08bce..0f9ebb4 100644 --- a/Utils/FaIconDrawer.cs +++ b/Utils/FaIconDrawer.cs @@ -8,49 +8,49 @@ public partial class FaIconDrawer(ILogger logger) : IDisposable private readonly ILogger _logger = logger; private Dictionary _cache = new(); - public SKSize? DrawIcon(string iconName, int width, int x, int y, + public SKSize? DrawIcon(string iconName, int height, int x, int y, SKCanvas canvas) { - var icon = GetIcon(iconName, width); + var icon = GetIcon(iconName, height); if (icon == null) return null; canvas.DrawImage(icon.Image, x, y); return icon.Size; } - public SKSize? DrawIcon(string iconName, int width, SKPoint point, SKCanvas canvas) + public SKSize? DrawIcon(string iconName, int height, SKPoint point, SKCanvas canvas) { - var icon = GetIcon(iconName, width); + var icon = GetIcon(iconName, height); if (icon == null) return null; canvas.DrawImage(icon.Image, point); return icon.Size; } - public SKSize? GetSize(string iconName, int width) + public SKSize? GetSize(string iconName, int height) { - var icon = GetIcon(iconName, width); + var icon = GetIcon(iconName, height); if (icon == null) return null; return icon.Size; } - private CacheEntry? GetIcon(string iconName, int width) + private CacheEntry? GetIcon(string iconName, int height) { - var key = new CacheKey(iconName, width); + var key = new CacheKey(iconName, height); if (_cache.TryGetValue(key, out var icon)) return icon; - icon = CreateIcon(iconName, width); + icon = CreateIcon(iconName, height); if (icon == null) return null; _cache.Add(key, icon); return icon; } - private CacheEntry? CreateIcon(string iconName, int width) + private CacheEntry? CreateIcon(string iconName, int height) { - LogCreating(iconName, width); + LogCreating(iconName, height); var iconPath = $"./Resources/svgs/light/{iconName}.svg"; if (!File.Exists(iconPath)) @@ -65,8 +65,8 @@ public partial class FaIconDrawer(ILogger logger) : IDisposable svgImage.Load(stream); var info = svgImage.CanvasSize; - var widthFactor = width / info.Width; - var desiredSize = new SKSize(width, widthFactor * info.Height); + var heightFactor = height / info.Height; + var desiredSize = new SKSize(heightFactor * info.Width, height); // Draw to Bitmap var imageInfo = new SKImageInfo((int)desiredSize.Width, (int)desiredSize.Height); @@ -87,7 +87,7 @@ public partial class FaIconDrawer(ILogger logger) : IDisposable return new CacheEntry(data, desiredSize); } - private record CacheKey(string IconId, int Width); + private record CacheKey(string IconId, int Height); private record CacheEntry(SKImage Image, SKSize Size) : IDisposable { public void Dispose() @@ -99,8 +99,8 @@ public void Dispose() [LoggerMessage(LogLevel.Warning, "Icon {iconName} not found!")] private partial void LogFileNotFound(string iconName); - [LoggerMessage(LogLevel.Debug, "Create Icon {iconName} with width {width}")] - private partial void LogCreating(string iconName, int width); + [LoggerMessage(LogLevel.Debug, "Create Icon {iconName} with height {height}")] + private partial void LogCreating(string iconName, int height); public void Dispose() { diff --git a/Utils/HassExtensions.cs b/Utils/HassExtensions.cs new file mode 100644 index 0000000..cf04c31 --- /dev/null +++ b/Utils/HassExtensions.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Options; +using NetDaemon.Client; +using NetDaemon.Client.Extensions; +using NetDaemon.Client.Settings; +using NetDaemon.HassModel; + +namespace DisplayUtil.Utils; + +public static class HassExtension +{ + public static IHostApplicationBuilder AddHassSupport(this IHostApplicationBuilder builder) + { + + builder.Services.Configure( + builder.Configuration.GetSection("HomeAssistant") + ); + + builder.Services.AddHomeAssistantClient(); + + // Hack: Initialize Hass Model + var extensionType = typeof(DependencyInjectionSetup); + var methodInfo = extensionType.GetMethod("AddScopedHaContext", + System.Reflection.BindingFlags.NonPublic + | System.Reflection.BindingFlags.Static + ) + ?? throw new Exception("Error injecting Hass Model"); + + methodInfo.Invoke(null, [builder.Services]); + + // Background Connection + builder.Services.AddHostedService(); + + return builder; + } +} \ No newline at end of file diff --git a/Utils/HassHostedService.cs b/Utils/HassHostedService.cs new file mode 100644 index 0000000..7d59946 --- /dev/null +++ b/Utils/HassHostedService.cs @@ -0,0 +1,45 @@ + +using Microsoft.Extensions.Options; +using NetDaemon.Client; +using NetDaemon.Client.Settings; +using NetDaemon.HassModel; + +namespace DisplayUtil.Utils; + +internal class HassHostedService( + IOptions options, + IHomeAssistantRunner haRunner, + ICacheManager cacheManager, + ILogger logger +) : IHostedService +{ + private CancellationTokenSource _cancellationTokenSource = new(); + + public async Task StartAsync(CancellationToken cancellationToken) + { + var haSettings = options.Value; + + haRunner.OnConnect.SubscribeAsync(OnConnection); + + _ = haRunner.RunAsync( + haSettings.Host, + haSettings.Port, + haSettings.Ssl, + haSettings.Token, + TimeSpan.FromSeconds(10), + _cancellationTokenSource.Token + ); + } + + private async Task OnConnection(IHomeAssistantConnection connection) + { + logger.LogInformation("Hass Connection initialized"); + await cacheManager.InitializeAsync(_cancellationTokenSource.Token); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _cancellationTokenSource.Cancel(); + return Task.CompletedTask; + } +} \ No newline at end of file