V18 / 8d896b8a86e5
HomeSession.cs
Lilith/Core/HomeAutomation/HomeSession.cs
Unchanged from the preceding versionCS
using System.Globalization;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Core.Workspace;
namespace Lilith.Core.HomeAutomation;
public sealed class HomeSession : IDisposable
{
private sealed record Store(Dictionary<string, LightState> Lights, Dictionary<string, HomeProposal> Pending, Dictionary<string, HomeResult> Receipts);
private readonly HomeOptions _options;
private readonly HomeDevice[] _devices;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly FileStream _lease;
private readonly string _path;
private Store _store;
public HomeSession(HomeOptions options)
{
_options = options;
_devices = options.Devices.Select(d => d with { Capabilities = d.Capabilities.ToArray() }).ToArray();
var names = new Dictionary<string,string>(StringComparer.Ordinal);
foreach (var d in _devices)
{
if (!Regex.IsMatch(d.Id, "^[a-z][a-z0-9_]{0,63}$") || string.IsNullOrWhiteSpace(d.Name) || d.Name.Length > 100 || d.Capabilities.Length == 0 || d.Capabilities.Any(c => c is not ("power" or "brightness" or "color")) || d.Transport is not ("dummy" or "http")) throw new ArgumentException("Invalid home device.");
foreach (string alias in new[]{Normalize(d.Id),Normalize(d.Name)}) { if (names.TryGetValue(alias,out var previous) && previous != d.Id) throw new ArgumentException("Ambiguous device names."); names[alias]=d.Id; }
if (_devices.Count(x => x.Id == d.Id) != 1) throw new ArgumentException("Duplicate device ID.");
if (d.Transport == "http") { HomeOptions.ValidateEndpoint(d.Endpoint); if (string.IsNullOrWhiteSpace(d.TokenEnvironment)) throw new ArgumentException("Specify a device token environment-variable name."); }
}
Directory.CreateDirectory(options.RootDirectory);
_lease = new(Path.Combine(options.RootDirectory, ".lease"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
_path = Path.Combine(options.RootDirectory, "home.json");
try { _store = File.Exists(_path) ? JsonSerializer.Deserialize<Store>(File.ReadAllText(_path)) ?? throw new InvalidDataException("Invalid home state.") : new(new(),new(),new()); }
catch { _lease.Dispose(); throw; }
}
public void Dispose() { _lease.Dispose(); _gate.Dispose(); }
private static string Normalize(string value) => string.Concat(value.Where(char.IsLetterOrDigit)).ToLowerInvariant();
private HomeDevice Device(string name) => _devices.SingleOrDefault(d => Normalize(d.Id) == Normalize(name) || Normalize(d.Name) == Normalize(name)) ?? throw new ArgumentException("Unknown configured device. Use home_devices first.");
private static string Hash(HomeDevice d) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(d))));
private void Save()
{
string temp = _path + ".tmp"; File.WriteAllText(temp,JsonSerializer.Serialize(_store)); File.Move(temp,_path,true);
}
public async Task<HomeResult> ExecuteAsync(string action, string device = "", string value = "", CancellationToken ct = default)
{
await _gate.WaitAsync(ct); try
{
if (action == "devices") return new("completed", _devices.Length==0 ? "No home devices are configured." : "Configured home devices: " + string.Join(", ",_devices.Select(d=>d.Id+" ("+d.Transport+")")), Data:_devices.Select(d=>new{d.Id,d.Name,d.Capabilities,Source=d.Transport}).ToArray());
var target = Device(device);
if (action == "state") return await StateAsync(target,ct);
if (!target.Capabilities.Contains(action)) throw new ArgumentException("Device does not support this task.");
object typed = action switch
{
"power" when value is "true" or "false" => value == "true",
"brightness" when int.TryParse(value,NumberStyles.None,CultureInfo.InvariantCulture,out int percent) && percent is >= 0 and <= 100 => percent,
"color" when Regex.IsMatch(value,"^#[0-9a-fA-F]{6}$") => value.ToUpperInvariant(),
_ => throw new ArgumentException("Use power true/false, brightness 0-100, or color #RRGGBB.")
};
var proposal = new HomeProposal(Guid.NewGuid().ToString("N"),target.Id,Hash(target),action,JsonSerializer.SerializeToElement(typed));
if (_store.Pending.Count >= 100) throw new InvalidOperationException("Review pending home proposals first.");
_store.Pending.Add(proposal.Id,proposal); Save();
if (_options.Mode == WorkspaceAccessMode.Ask) return new("needs_approval",$"Prepared {target.Name}: {action} = {proposal.Value}. Nothing sent. Approve with /home-approve {proposal.Id}; reject with /home-reject {proposal.Id}.",proposal.Id,proposal,Mutation:true);
return await ApplyAsync(proposal,ct);
} finally { _gate.Release(); }
}
public async Task<HomeResult> DecideAsync(string id, bool approve, CancellationToken ct = default)
{
await _gate.WaitAsync(ct); try
{
if (_store.Receipts.TryGetValue(id,out var receipt)) return receipt;
if (!_store.Pending.TryGetValue(id,out var proposal)) throw new ArgumentException("No matching home proposal.");
if (approve) return await ApplyAsync(proposal,ct);
return Complete(proposal,new("rejected","Home proposal rejected. Nothing sent.",Mutation:true));
} finally { _gate.Release(); }
}
public async Task<string> ReviewAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct); try { return JsonSerializer.Serialize(_store.Pending.Values); } finally { _gate.Release(); }
}
private HomeResult Complete(HomeProposal proposal,HomeResult result)
{
_store.Pending.Remove(proposal.Id); _store.Receipts[proposal.Id]=result;
while(_store.Receipts.Count>100) _store.Receipts.Remove(_store.Receipts.Keys.First()); Save(); return result;
}
private async Task<HomeResult> ApplyAsync(HomeProposal proposal,CancellationToken ct)
{
var target=Device(proposal.Device);
if(Hash(target)!=proposal.ConfigHash) throw new InvalidOperationException("Device configuration changed; reject this proposal and create a fresh one.");
if(target.Transport=="dummy")
{
var state=_store.Lights.GetValueOrDefault(target.Id)??new();
state=proposal.Action switch {"power"=>state with{Power=proposal.Value.GetBoolean()},"brightness"=>state with{Brightness=proposal.Value.GetInt32()},_=>state with{Color=proposal.Value.GetString()!}};
_store.Lights[target.Id]=state;
return Complete(proposal,new("simulated",$"Simulated {target.Name}: power {(state.Power?"on":"off")}, brightness {state.Brightness}%, color {state.Color}. No hardware was contacted.",Data:state,Mutation:true));
}
// Consume before network dispatch. An interruption can never replay a physical action.
Complete(proposal,new("unverified","Device outcome unverified. Read home_state before issuing another command.",Mutation:true));
try
{
using var doc=JsonDocument.Parse(await RequestAsync(target,new{requestId=proposal.Id,device=target.Id,action=proposal.Action,value=proposal.Value},ct));
var root=doc.RootElement;
if(root.GetProperty("requestId").GetString()!=proposal.Id || root.GetProperty("device").GetString()!=target.Id || root.GetProperty("status").GetString()!="applied") throw new InvalidDataException("Mismatched device acknowledgement.");
var state=ParseState(root.GetProperty("state"));
bool matches=proposal.Action switch {"power"=>state.Power==proposal.Value.GetBoolean(),"brightness"=>state.Brightness==proposal.Value.GetInt32(),_=>state.Color.Equals(proposal.Value.GetString(),StringComparison.OrdinalIgnoreCase)};
if(!matches) throw new InvalidDataException("Device did not acknowledge the requested setting.");
return Complete(proposal,new("acknowledged",$"{target.Name} acknowledged {proposal.Action} = {proposal.Value}. Reported state: power {(state.Power?"on":"off")}, brightness {state.Brightness}%, color {state.Color}.",Data:state,Mutation:true));
}
catch(Exception ex) when(ex is HttpRequestException or IOException or InvalidDataException or JsonException or InvalidOperationException or KeyNotFoundException or OperationCanceledException)
{ if(ct.IsCancellationRequested) throw; return _store.Receipts[proposal.Id]; }
}
private async Task<HomeResult> StateAsync(HomeDevice device,CancellationToken ct)
{
if(device.Transport=="dummy")
{
var state=_store.Lights.GetValueOrDefault(device.Id)??new();
return new("simulated",$"Simulated {device.Name}: power {(state.Power?"on":"off")}, brightness {state.Brightness}%, color {state.Color}. No hardware was contacted.",Data:state);
}
using var doc=JsonDocument.Parse(await RequestAsync(device,null,ct));
if(doc.RootElement.GetProperty("device").GetString()!=device.Id || doc.RootElement.GetProperty("status").GetString()!="state") throw new InvalidDataException("Invalid device state identity.");
var read=ParseState(doc.RootElement.GetProperty("state"));
return new("reported",$"{device.Name} reports power {(read.Power?"on":"off")}, brightness {read.Brightness}%, color {read.Color}.",Data:read);
}
private static LightState ParseState(JsonElement state)
{
var result=state.Deserialize<LightState>(new JsonSerializerOptions(JsonSerializerDefaults.Web))??throw new InvalidDataException("Missing light state.");
if(!state.TryGetProperty("power",out _) || !state.TryGetProperty("brightness",out _) || !state.TryGetProperty("color",out _) || result.Brightness is <0 or >100 || !Regex.IsMatch(result.Color,"^#[0-9A-Fa-f]{6}$")) throw new InvalidDataException("Invalid light state.");
return result;
}
private static async Task<string> RequestAsync(HomeDevice device,object? payload,CancellationToken ct)
{
var endpoint=HomeOptions.ValidateEndpoint(device.Endpoint);
string token=Environment.GetEnvironmentVariable(device.TokenEnvironment!)??"";
if(token.Length is <16 or >128 || token.Any(char.IsControl)) throw new InvalidOperationException("Device authentication is not configured.");
using var handler=new HttpClientHandler{AllowAutoRedirect=false,UseCookies=false,UseProxy=false};
using var http=new HttpClient(handler){BaseAddress=endpoint,Timeout=TimeSpan.FromSeconds(5),MaxResponseContentBufferSize=16000};
http.DefaultRequestHeaders.Add("X-Lilith-Key",token);
using var response=payload is null?await http.GetAsync("api/light",ct):await http.PostAsJsonAsync("api/light",payload,ct);
response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(ct);
}
}