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:
+3
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,8 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"IntentorDb": "Data Source=/data/intentor.db"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,6 @@ startup: application
|
||||
ports:
|
||||
8080/tcp: 8080
|
||||
webui: "http://[HOST]:[PORT:8080]"
|
||||
homeassistant_api: true
|
||||
homeassistant_api: true
|
||||
map:
|
||||
- data:rw
|
||||
Reference in New Issue
Block a user