From e063e09bd3696937a19a21c68de7fcf105b5f673 Mon Sep 17 00:00:00 2001 From: hidder11 Date: Sat, 31 Jan 2026 16:47:41 +0100 Subject: [PATCH] Add entity, repository, and services for managing intents and dependencies. Includes database schema updates, action execution, and dependency resolution logic. --- Intentor/Dockerfile | 4 +- Intentor/Intentor.csproj | 5 ++ Intentor/IntentorDbContext.cs | 65 ++++++++++++++++ Intentor/Models/HomeAssistantAction.cs | 20 +++++ Intentor/Models/HomeAssistantActionType.cs | 7 ++ Intentor/Models/Intent.cs | 29 +++++++ Intentor/Models/IntentActivation.cs | 40 ++++++++++ Intentor/Models/IntentRequirement.cs | 19 +++++ Intentor/Models/SpaceModel.cs | 29 +++++++ Intentor/Program.cs | 17 ++++ Intentor/Repositories/IIntentRepository.cs | 23 ++++++ .../Repositories/IntentActivationHandler.cs | 74 ++++++++++++++++++ .../IntentActivationRepository.cs | 25 ++++++ Intentor/Repositories/IntentRepository.cs | 77 +++++++++++++++++++ Intentor/Services/HomeAssistantService.cs | 18 +++++ Intentor/Services/IntentActionExecutor.cs | 29 +++++++ Intentor/appsettings.json | 5 +- Intentor/config.yaml | 6 +- 18 files changed, 488 insertions(+), 4 deletions(-) create mode 100644 Intentor/IntentorDbContext.cs create mode 100644 Intentor/Models/HomeAssistantAction.cs create mode 100644 Intentor/Models/HomeAssistantActionType.cs create mode 100644 Intentor/Models/Intent.cs create mode 100644 Intentor/Models/IntentActivation.cs create mode 100644 Intentor/Models/IntentRequirement.cs create mode 100644 Intentor/Models/SpaceModel.cs create mode 100644 Intentor/Repositories/IIntentRepository.cs create mode 100644 Intentor/Repositories/IntentActivationHandler.cs create mode 100644 Intentor/Repositories/IntentActivationRepository.cs create mode 100644 Intentor/Repositories/IntentRepository.cs create mode 100644 Intentor/Services/IntentActionExecutor.cs diff --git a/Intentor/Dockerfile b/Intentor/Dockerfile index c5059af..d283812 100644 --- a/Intentor/Dockerfile +++ b/Intentor/Dockerfile @@ -1,6 +1,8 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base -USER $APP_UID +USER root WORKDIR /app +RUN mkdir -p /data && chown -R $APP_UID:$APP_UID /data +USER $APP_UID EXPOSE 8080 EXPOSE 8081 diff --git a/Intentor/Intentor.csproj b/Intentor/Intentor.csproj index 7147849..3d913aa 100644 --- a/Intentor/Intentor.csproj +++ b/Intentor/Intentor.csproj @@ -14,4 +14,9 @@ + + + + + diff --git a/Intentor/IntentorDbContext.cs b/Intentor/IntentorDbContext.cs new file mode 100644 index 0000000..1598dc2 --- /dev/null +++ b/Intentor/IntentorDbContext.cs @@ -0,0 +1,65 @@ +using Intentor.Models; +using Microsoft.EntityFrameworkCore; + +namespace Intentor.Data; + +public sealed class IntentorDbContext : DbContext +{ + public IntentorDbContext(DbContextOptions options) : base(options) { } + + public DbSet Spaces => Set(); + public DbSet Intents => Set(); + public DbSet IntentActivations => Set(); + public DbSet IntentRequirements => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .HasMany(s => s.Intents) + .WithOne(i => i.Space) + .HasForeignKey(i => i.SpaceId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .OwnsOne(i => i.Action, action => + { + action.Property(a => a.Type).HasColumnName("ActionType"); + action.Property(a => a.EntityId).HasColumnName("ActionEntityId").HasMaxLength(255); + }); + + modelBuilder.Entity() + .HasOne(a => a.Intent) + .WithMany(i => i.Activations) + .HasForeignKey(a => a.IntentId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(a => new { a.SpaceId, a.Until }); + + modelBuilder.Entity() + .HasIndex(a => new { a.SpaceId, a.CreatedAt }); + + modelBuilder.Entity() + .HasKey(x => new { x.IntentId, x.RequiredIntentId }); + + modelBuilder.Entity() + .HasOne(x => x.Intent) + .WithMany(i => i.Requirements) + .HasForeignKey(x => x.IntentId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(x => x.RequiredIntent) + .WithMany(i => i.RequiredBy) + .HasForeignKey(x => x.RequiredIntentId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(i => new { i.SpaceId, i.Priority }); + + modelBuilder.Entity() + .HasIndex(i => i.Name); + } +} diff --git a/Intentor/Models/HomeAssistantAction.cs b/Intentor/Models/HomeAssistantAction.cs new file mode 100644 index 0000000..32e6fa7 --- /dev/null +++ b/Intentor/Models/HomeAssistantAction.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Intentor.Models; + +/// +/// Describes what to do in Home Assistant when an Intent is executed. +/// Stored as part of Intent (owned type). +/// +public sealed class HomeAssistantAction +{ + [Required] + public HomeAssistantActionType Type { get; set; } + + /// + /// The HA entity id, e.g. "scene.relax" or "script.good_night". + /// + [Required] + [MaxLength(255)] + public string EntityId { get; set; } = string.Empty; +} diff --git a/Intentor/Models/HomeAssistantActionType.cs b/Intentor/Models/HomeAssistantActionType.cs new file mode 100644 index 0000000..e1c7cf7 --- /dev/null +++ b/Intentor/Models/HomeAssistantActionType.cs @@ -0,0 +1,7 @@ +namespace Intentor.Models; + +public enum HomeAssistantActionType +{ + ActivateScene = 1, + RunScript = 2 +} diff --git a/Intentor/Models/Intent.cs b/Intentor/Models/Intent.cs new file mode 100644 index 0000000..e97c0b2 --- /dev/null +++ b/Intentor/Models/Intent.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +namespace Intentor.Models; + +public class Intent +{ + [Key] + public int Id { get; set; } + + [Required] + public int SpaceId { get; set; } + + [Required] + public Space Space { get; set; } = null!; + + [Required] + [MaxLength(200)] + public string Name { get; set; } = string.Empty; + + [Required] + public HomeAssistantAction Action { get; set; } = new(); + + public int Priority { get; set; } + + // Activations that currently (or historically) put this intent on the stack + public ICollection Activations { get; set; } = new List(); + + public ICollection Requirements { get; set; } = new List(); + public ICollection RequiredBy { get; set; } = new List(); +} \ No newline at end of file diff --git a/Intentor/Models/IntentActivation.cs b/Intentor/Models/IntentActivation.cs new file mode 100644 index 0000000..72c7c4d --- /dev/null +++ b/Intentor/Models/IntentActivation.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Intentor.Models; + +public class IntentActivation +{ + [Key] + public long Id { get; set; } + + [Required] + public int SpaceId { get; set; } + + [ForeignKey(nameof(SpaceId))] + public Space Space { get; set; } = null!; + + [Required] + public int IntentId { get; set; } + + [ForeignKey(nameof(IntentId))] + public Intent Intent { get; set; } = null!; + + /// + /// When this activation was created. Useful for tie-breaking. + /// + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + /// + /// If set: activation automatically expires at this time. + /// If null: activation stays until explicitly removed. + /// + public DateTimeOffset? Until { get; set; } + + /// + /// Optional: who/what created this activation (e.g. "ha:binary_sensor.hallway_motion"). + /// Helps you update/remove a specific activation later. + /// + [MaxLength(200)] + public string? Source { get; set; } +} diff --git a/Intentor/Models/IntentRequirement.cs b/Intentor/Models/IntentRequirement.cs new file mode 100644 index 0000000..3537014 --- /dev/null +++ b/Intentor/Models/IntentRequirement.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Intentor.Models; + +/// +/// Join entity: "Intent A requires Intent B". +/// +public class IntentRequirement +{ + public int IntentId { get; set; } + + public int RequiredIntentId { get; set; } + + [ForeignKey(nameof(IntentId))] + public Intent Intent { get; set; } = null!; + + [ForeignKey(nameof(RequiredIntentId))] + public Intent RequiredIntent { get; set; } = null!; +} diff --git a/Intentor/Models/SpaceModel.cs b/Intentor/Models/SpaceModel.cs new file mode 100644 index 0000000..3c20798 --- /dev/null +++ b/Intentor/Models/SpaceModel.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace Intentor.Models; + +public enum SpaceKind +{ + HomeAssistant = 1, + Virtual = 2 +} + +public class Space +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(100)] + public string Name { get; set; } = string.Empty; + + public SpaceKind Kind { get; set; } = SpaceKind.HomeAssistant; + + /// + /// Optional key/slug for stable references, e.g. "living-room", "garden", "global". + /// + [MaxLength(100)] + public string? Key { get; set; } + + public ICollection Intents { get; set; } = new List(); +} diff --git a/Intentor/Program.cs b/Intentor/Program.cs index 5713acc..504e173 100644 --- a/Intentor/Program.cs +++ b/Intentor/Program.cs @@ -1,5 +1,8 @@ using Intentor.Components; +using Intentor.Data; +using Intentor.Logic; using Intentor.Services; +using Microsoft.EntityFrameworkCore; namespace Intentor; @@ -21,11 +24,25 @@ public class Program builder.Services.AddSingleton(sp => new HomeAssistantService(sp.GetRequiredService().CreateClient(nameof(HomeAssistantService)), supervisorToken)); + builder.Services.AddDbContext(options => + options.UseSqlite(builder.Configuration.GetConnectionString("IntentorDb"))); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); var app = builder.Build(); + // Create database file + schema (no migrations tooling required) + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + } + // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { diff --git a/Intentor/Repositories/IIntentRepository.cs b/Intentor/Repositories/IIntentRepository.cs new file mode 100644 index 0000000..fb1bcd7 --- /dev/null +++ b/Intentor/Repositories/IIntentRepository.cs @@ -0,0 +1,23 @@ +using Intentor.Models; + +namespace Intentor.Data; + +public interface IIntentRepository +{ + Task> GetAllAsync(CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + + /// + /// Returns an intent including Requirements/RequiredBy graph. + /// + Task GetByIdWithDependenciesAsync(int id, CancellationToken ct = default); + + Task AddAsync(Intent intent, CancellationToken ct = default); + Task UpdateAsync(Intent intent, CancellationToken ct = default); + Task DeleteAsync(int id, CancellationToken ct = default); + + /// + /// Sets "intent requires [requiredIntentIds]" (replaces existing requirements). + /// + Task SetRequirementsAsync(int intentId, IEnumerable requiredIntentIds, CancellationToken ct = default); +} diff --git a/Intentor/Repositories/IntentActivationHandler.cs b/Intentor/Repositories/IntentActivationHandler.cs new file mode 100644 index 0000000..8a85f70 --- /dev/null +++ b/Intentor/Repositories/IntentActivationHandler.cs @@ -0,0 +1,74 @@ +using Intentor.Models; +using Microsoft.EntityFrameworkCore; + +namespace Intentor.Data; + +public sealed class IntentActivationRepository(IntentorDbContext db) : IIntentActivationRepository +{ + public async Task ActivateAsync( + int spaceId, + int intentId, + DateTimeOffset? until = null, + string? source = null, + CancellationToken ct = default) + { + var activation = new IntentActivation + { + SpaceId = spaceId, + IntentId = intentId, + Until = until, + Source = source, + CreatedAt = DateTimeOffset.UtcNow + }; + + db.IntentActivations.Add(activation); + await db.SaveChangesAsync(ct); + return activation; + } + + public async Task DeactivateAsync(long activationId, CancellationToken ct = default) + { + var entity = await db.IntentActivations.FirstOrDefaultAsync(a => a.Id == activationId, ct); + if (entity is null) return; + + db.IntentActivations.Remove(entity); + await db.SaveChangesAsync(ct); + } + + public async Task DeactivateBySourceAsync(int spaceId, string source, CancellationToken ct = default) + { + var entities = await db.IntentActivations + .Where(a => a.SpaceId == spaceId && a.Source == source) + .ToListAsync(ct); + + if (entities.Count == 0) return 0; + + db.IntentActivations.RemoveRange(entities); + return await db.SaveChangesAsync(ct); + } + + public async Task CleanupExpiredAsync(DateTimeOffset now, CancellationToken ct = default) + { + var expired = await db.IntentActivations + .Where(a => a.Until != null && a.Until < now) + .ToListAsync(ct); + + if (expired.Count == 0) return 0; + + db.IntentActivations.RemoveRange(expired); + return await db.SaveChangesAsync(ct); + } + + public Task GetEffectiveIntentAsync(int spaceId, DateTimeOffset now, CancellationToken ct = default) + { + // Active activations: Until == null OR Until >= now + // Winner rule: highest intent priority; tie-breaker: most recently activated + return db.IntentActivations + .AsNoTracking() + .Where(a => a.SpaceId == spaceId && (a.Until == null || a.Until >= now)) + .OrderByDescending(a => a.Intent.Priority) + .ThenByDescending(a => a.CreatedAt) + .Select(a => a.Intent) + .FirstOrDefaultAsync(ct); + } +} diff --git a/Intentor/Repositories/IntentActivationRepository.cs b/Intentor/Repositories/IntentActivationRepository.cs new file mode 100644 index 0000000..f2a8ff2 --- /dev/null +++ b/Intentor/Repositories/IntentActivationRepository.cs @@ -0,0 +1,25 @@ +using Intentor.Models; + +namespace Intentor.Data; + +public interface IIntentActivationRepository +{ + Task ActivateAsync( + int spaceId, + int intentId, + DateTimeOffset? until = null, + string? source = null, + CancellationToken ct = default); + + Task DeactivateAsync(long activationId, CancellationToken ct = default); + + Task DeactivateBySourceAsync(int spaceId, string source, CancellationToken ct = default); + + Task CleanupExpiredAsync(DateTimeOffset now, CancellationToken ct = default); + + /// + /// Returns the currently "effective" intent for a space (highest priority among active activations), + /// including its Action. + /// + Task GetEffectiveIntentAsync(int spaceId, DateTimeOffset now, CancellationToken ct = default); +} diff --git a/Intentor/Repositories/IntentRepository.cs b/Intentor/Repositories/IntentRepository.cs new file mode 100644 index 0000000..e101a84 --- /dev/null +++ b/Intentor/Repositories/IntentRepository.cs @@ -0,0 +1,77 @@ +using Intentor.Models; +using Microsoft.EntityFrameworkCore; + +namespace Intentor.Data; + +public sealed class IntentRepository(IntentorDbContext db) : IIntentRepository +{ + public Task> GetAllAsync(CancellationToken ct = default) => + db.Intents + .AsNoTracking() + .OrderByDescending(i => i.Priority) + .ThenBy(i => i.Name) + .ToListAsync(ct); + + public Task GetByIdAsync(int id, CancellationToken ct = default) => + db.Intents.FirstOrDefaultAsync(i => i.Id == id, ct); + + public Task GetByIdWithDependenciesAsync(int id, CancellationToken ct = default) => + db.Intents + .AsNoTracking() + .Include(i => i.Requirements) + .ThenInclude(r => r.RequiredIntent) + .Include(i => i.RequiredBy) + .ThenInclude(r => r.Intent) + .FirstOrDefaultAsync(i => i.Id == id, ct); + + public async Task AddAsync(Intent intent, CancellationToken ct = default) + { + db.Intents.Add(intent); + await db.SaveChangesAsync(ct); + return intent; + } + + public async Task UpdateAsync(Intent intent, CancellationToken ct = default) + { + db.Intents.Update(intent); + await db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(int id, CancellationToken ct = default) + { + var entity = await db.Intents.FirstOrDefaultAsync(i => i.Id == id, ct); + if (entity is null) return; + + db.Intents.Remove(entity); + await db.SaveChangesAsync(ct); + } + + public async Task SetRequirementsAsync(int intentId, IEnumerable requiredIntentIds, CancellationToken ct = default) + { + var distinctIds = requiredIntentIds + .Where(x => x > 0) + .Distinct() + .ToList(); + + // Prevent self-dependency + distinctIds.Remove(intentId); + + // Replace existing requirements + var existing = await db.IntentRequirements + .Where(r => r.IntentId == intentId) + .ToListAsync(ct); + + db.IntentRequirements.RemoveRange(existing); + + foreach (var requiredId in distinctIds) + { + db.IntentRequirements.Add(new IntentRequirement + { + IntentId = intentId, + RequiredIntentId = requiredId + }); + } + + await db.SaveChangesAsync(ct); + } +} diff --git a/Intentor/Services/HomeAssistantService.cs b/Intentor/Services/HomeAssistantService.cs index 49894ab..36b193a 100644 --- a/Intentor/Services/HomeAssistantService.cs +++ b/Intentor/Services/HomeAssistantService.cs @@ -20,6 +20,16 @@ public class HomeAssistantService return states ?? new List(); } + // Calls a HA service like scene.turn_on or script.turn_on + public Task CallServiceAsync(string domain, string service, object data, CancellationToken ct = default) + => PostAsync($"core/api/services/{domain}/{service}", data, ct); + + public Task ActivateSceneAsync(string sceneEntityId, CancellationToken ct = default) + => CallServiceAsync("scene", "turn_on", new { entity_id = sceneEntityId }, ct); + + public Task RunScriptAsync(string scriptEntityId, CancellationToken ct = default) + => CallServiceAsync("script", "turn_on", new { entity_id = scriptEntityId }, ct); + public async Task GetAsync(string endpoint) { var response = await _httpClient.GetAsync($"{SupervisorApiBase}/{endpoint}"); @@ -34,6 +44,14 @@ public class HomeAssistantService return response; } + // Overload with CancellationToken (so UI can cancel / avoid hanging requests) + public async Task PostAsync(string endpoint, object? content, CancellationToken ct) + { + var response = await _httpClient.PostAsJsonAsync($"{SupervisorApiBase}/{endpoint}", content, ct); + response.EnsureSuccessStatusCode(); + return response; + } + public async Task PostAsync(string endpoint, object? content = null) { var response = await _httpClient.PostAsJsonAsync($"{SupervisorApiBase}/{endpoint}", content); diff --git a/Intentor/Services/IntentActionExecutor.cs b/Intentor/Services/IntentActionExecutor.cs new file mode 100644 index 0000000..4ea7925 --- /dev/null +++ b/Intentor/Services/IntentActionExecutor.cs @@ -0,0 +1,29 @@ +using Intentor.Models; +using Intentor.Services; + +namespace Intentor.Logic; + +public interface IIntentActionExecutor +{ + Task ExecuteAsync(Intent intent, CancellationToken ct = default); +} + +public sealed class IntentActionExecutor : IIntentActionExecutor +{ + private readonly HomeAssistantService _homeAssistant; + + public IntentActionExecutor(HomeAssistantService homeAssistant) => _homeAssistant = homeAssistant; + + public Task ExecuteAsync(Intent intent, CancellationToken ct = default) + { + if (intent.Action is null) + throw new InvalidOperationException("Intent.Action is required."); + + return intent.Action.Type switch + { + HomeAssistantActionType.ActivateScene => _homeAssistant.ActivateSceneAsync(intent.Action.EntityId, ct), + HomeAssistantActionType.RunScript => _homeAssistant.RunScriptAsync(intent.Action.EntityId, ct), + _ => throw new NotSupportedException($"Unsupported action type: {intent.Action.Type}") + }; + } +} diff --git a/Intentor/appsettings.json b/Intentor/appsettings.json index 10f68b8..9c6004d 100644 --- a/Intentor/appsettings.json +++ b/Intentor/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ConnectionStrings": { + "IntentorDb": "Data Source=/data/intentor.db" + } } diff --git a/Intentor/config.yaml b/Intentor/config.yaml index 008a9e3..e55c10b 100644 --- a/Intentor/config.yaml +++ b/Intentor/config.yaml @@ -1,6 +1,6 @@ name: "Intentor" description: "Intent based automation platform" -version: 0.0.6 +version: 0.0.7 slug: "intentor" init: false arch: @@ -10,4 +10,6 @@ startup: application ports: 8080/tcp: 8080 webui: "http://[HOST]:[PORT:8080]" -homeassistant_api: true \ No newline at end of file +homeassistant_api: true +map: + - data:rw \ No newline at end of file