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