← All source files

V17 / d6d373561692

HomeSession.cs

Lilith/Core/HomeAutomation/HomeSession.cs

Unchanged from the preceding version
CS
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);
    }
}