Add entity, repository, and services for managing intents and dependencies. Includes database schema updates, action execution, and dependency resolution logic.

This commit is contained in:
2026-01-31 16:47:41 +01:00
parent f28c845950
commit 50866219fd
18 changed files with 487 additions and 3 deletions
+3 -1
View File
@@ -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
+5
View File
@@ -14,4 +14,9 @@
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
</ItemGroup>
</Project>
+65
View File
@@ -0,0 +1,65 @@
using Intentor.Models;
using Microsoft.EntityFrameworkCore;
namespace Intentor.Data;
public sealed class IntentorDbContext : DbContext
{
public IntentorDbContext(DbContextOptions<IntentorDbContext> options) : base(options) { }
public DbSet<Space> Spaces => Set<Space>();
public DbSet<Intent> Intents => Set<Intent>();
public DbSet<IntentActivation> IntentActivations => Set<IntentActivation>();
public DbSet<IntentRequirement> IntentRequirements => Set<IntentRequirement>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Space>()
.HasMany(s => s.Intents)
.WithOne(i => i.Space)
.HasForeignKey(i => i.SpaceId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Intent>()
.OwnsOne(i => i.Action, action =>
{
action.Property(a => a.Type).HasColumnName("ActionType");
action.Property(a => a.EntityId).HasColumnName("ActionEntityId").HasMaxLength(255);
});
modelBuilder.Entity<IntentActivation>()
.HasOne(a => a.Intent)
.WithMany(i => i.Activations)
.HasForeignKey(a => a.IntentId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<IntentActivation>()
.HasIndex(a => new { a.SpaceId, a.Until });
modelBuilder.Entity<IntentActivation>()
.HasIndex(a => new { a.SpaceId, a.CreatedAt });
modelBuilder.Entity<IntentRequirement>()
.HasKey(x => new { x.IntentId, x.RequiredIntentId });
modelBuilder.Entity<IntentRequirement>()
.HasOne(x => x.Intent)
.WithMany(i => i.Requirements)
.HasForeignKey(x => x.IntentId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<IntentRequirement>()
.HasOne(x => x.RequiredIntent)
.WithMany(i => i.RequiredBy)
.HasForeignKey(x => x.RequiredIntentId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Intent>()
.HasIndex(i => new { i.SpaceId, i.Priority });
modelBuilder.Entity<Intent>()
.HasIndex(i => i.Name);
}
}
+20
View File
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace Intentor.Models;
/// <summary>
/// Describes what to do in Home Assistant when an Intent is executed.
/// Stored as part of Intent (owned type).
/// </summary>
public sealed class HomeAssistantAction
{
[Required]
public HomeAssistantActionType Type { get; set; }
/// <summary>
/// The HA entity id, e.g. "scene.relax" or "script.good_night".
/// </summary>
[Required]
[MaxLength(255)]
public string EntityId { get; set; } = string.Empty;
}
@@ -0,0 +1,7 @@
namespace Intentor.Models;
public enum HomeAssistantActionType
{
ActivateScene = 1,
RunScript = 2
}
+29
View File
@@ -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<IntentActivation> Activations { get; set; } = new List<IntentActivation>();
public ICollection<IntentRequirement> Requirements { get; set; } = new List<IntentRequirement>();
public ICollection<IntentRequirement> RequiredBy { get; set; } = new List<IntentRequirement>();
}
+40
View File
@@ -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!;
/// <summary>
/// When this activation was created. Useful for tie-breaking.
/// </summary>
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>
/// If set: activation automatically expires at this time.
/// If null: activation stays until explicitly removed.
/// </summary>
public DateTimeOffset? Until { get; set; }
/// <summary>
/// Optional: who/what created this activation (e.g. "ha:binary_sensor.hallway_motion").
/// Helps you update/remove a specific activation later.
/// </summary>
[MaxLength(200)]
public string? Source { get; set; }
}
+19
View File
@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Intentor.Models;
/// <summary>
/// Join entity: "Intent A requires Intent B".
/// </summary>
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!;
}
+29
View File
@@ -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;
/// <summary>
/// Optional key/slug for stable references, e.g. "living-room", "garden", "global".
/// </summary>
[MaxLength(100)]
public string? Key { get; set; }
public ICollection<Intent> Intents { get; set; } = new List<Intent>();
}
+17
View File
@@ -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<IHttpClientFactory>().CreateClient(nameof(HomeAssistantService)), supervisorToken));
builder.Services.AddDbContext<IntentorDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("IntentorDb")));
builder.Services.AddScoped<IIntentRepository, IntentRepository>();
builder.Services.AddScoped<IIntentActivationRepository, IntentActivationRepository>();
builder.Services.AddScoped<IIntentActionExecutor, IntentActionExecutor>();
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<IntentorDbContext>();
db.Database.EnsureCreated();
}
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
@@ -0,0 +1,23 @@
using Intentor.Models;
namespace Intentor.Data;
public interface IIntentRepository
{
Task<List<Intent>> GetAllAsync(CancellationToken ct = default);
Task<Intent?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>
/// Returns an intent including Requirements/RequiredBy graph.
/// </summary>
Task<Intent?> GetByIdWithDependenciesAsync(int id, CancellationToken ct = default);
Task<Intent> AddAsync(Intent intent, CancellationToken ct = default);
Task UpdateAsync(Intent intent, CancellationToken ct = default);
Task DeleteAsync(int id, CancellationToken ct = default);
/// <summary>
/// Sets "intent requires [requiredIntentIds]" (replaces existing requirements).
/// </summary>
Task SetRequirementsAsync(int intentId, IEnumerable<int> requiredIntentIds, CancellationToken ct = default);
}
@@ -0,0 +1,74 @@
using Intentor.Models;
using Microsoft.EntityFrameworkCore;
namespace Intentor.Data;
public sealed class IntentActivationRepository(IntentorDbContext db) : IIntentActivationRepository
{
public async Task<IntentActivation> 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<int> 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<int> 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<Intent?> 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);
}
}
@@ -0,0 +1,25 @@
using Intentor.Models;
namespace Intentor.Data;
public interface IIntentActivationRepository
{
Task<IntentActivation> ActivateAsync(
int spaceId,
int intentId,
DateTimeOffset? until = null,
string? source = null,
CancellationToken ct = default);
Task DeactivateAsync(long activationId, CancellationToken ct = default);
Task<int> DeactivateBySourceAsync(int spaceId, string source, CancellationToken ct = default);
Task<int> CleanupExpiredAsync(DateTimeOffset now, CancellationToken ct = default);
/// <summary>
/// Returns the currently "effective" intent for a space (highest priority among active activations),
/// including its Action.
/// </summary>
Task<Intent?> GetEffectiveIntentAsync(int spaceId, DateTimeOffset now, CancellationToken ct = default);
}
+77
View File
@@ -0,0 +1,77 @@
using Intentor.Models;
using Microsoft.EntityFrameworkCore;
namespace Intentor.Data;
public sealed class IntentRepository(IntentorDbContext db) : IIntentRepository
{
public Task<List<Intent>> GetAllAsync(CancellationToken ct = default) =>
db.Intents
.AsNoTracking()
.OrderByDescending(i => i.Priority)
.ThenBy(i => i.Name)
.ToListAsync(ct);
public Task<Intent?> GetByIdAsync(int id, CancellationToken ct = default) =>
db.Intents.FirstOrDefaultAsync(i => i.Id == id, ct);
public Task<Intent?> 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<Intent> 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<int> 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);
}
}
+18
View File
@@ -20,6 +20,16 @@ public class HomeAssistantService
return states ?? new List<EntityState>();
}
// 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<T?> GetAsync<T>(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<HttpResponseMessage> PostAsync(string endpoint, object? content, CancellationToken ct)
{
var response = await _httpClient.PostAsJsonAsync($"{SupervisorApiBase}/{endpoint}", content, ct);
response.EnsureSuccessStatusCode();
return response;
}
public async Task<T?> PostAsync<T>(string endpoint, object? content = null)
{
var response = await _httpClient.PostAsJsonAsync($"{SupervisorApiBase}/{endpoint}", content);
+29
View File
@@ -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}")
};
}
}
+4 -1
View File
@@ -5,5 +5,8 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"ConnectionStrings": {
"IntentorDb": "Data Source=/data/intentor.db"
}
}
+2
View File
@@ -11,3 +11,5 @@ ports:
8080/tcp: 8080
webui: "http://[HOST]:[PORT:8080]"
homeassistant_api: true
map:
- data:rw