diff --git a/Admin.NET.Web.Core/ProjectOptions.cs b/Admin.NET.Web.Core/ProjectOptions.cs index b09238a..077c5fe 100644 --- a/Admin.NET.Web.Core/ProjectOptions.cs +++ b/Admin.NET.Web.Core/ProjectOptions.cs @@ -6,6 +6,7 @@ using Admin.NET.Core; using AspNetCoreRateLimit; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Furion; using Microsoft.Extensions.DependencyInjection; @@ -36,6 +37,7 @@ public static class ProjectOptions services.AddConfigurableOptions(); services.AddConfigurableOptions(); services.AddConfigurableOptions(); + services.AddConfigurableOptions(); services.Configure(App.Configuration.GetSection("IpRateLimiting")); services.Configure(App.Configuration.GetSection("IpRateLimitPolicies")); services.Configure(App.Configuration.GetSection("ClientRateLimiting")); diff --git a/Admin.NET.Web.Core/Startup.cs b/Admin.NET.Web.Core/Startup.cs index 3aceaa9..c5b06ea 100644 --- a/Admin.NET.Web.Core/Startup.cs +++ b/Admin.NET.Web.Core/Startup.cs @@ -8,6 +8,7 @@ using Admin.NET.Core; using Admin.NET.Core.ElasticSearch; using Admin.NET.Core.Service; using AspNetCoreRateLimit; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Furion; using Furion.Logging; using Furion.SpecificationDocument; @@ -74,6 +75,7 @@ public class Startup : AppStartup services.AddCorsAccessor(); // 远程请求 services.AddHttpRemote(); + services.AddHttpClient(); // 任务队列 services.AddTaskQueue(); // 脱敏检测 diff --git a/Evergreen.Health.Application/Configuration/HomeAssistant.json b/Evergreen.Health.Application/Configuration/HomeAssistant.json new file mode 100644 index 0000000..723ec9c --- /dev/null +++ b/Evergreen.Health.Application/Configuration/HomeAssistant.json @@ -0,0 +1,33 @@ +{ + "HomeAssistant": { + "Enabled": true, + "BaseUrl": "http://192.168.2.2:8123", + "TimeoutSeconds": 15, + "Entities": { + "Dehumidifier": "humidifier.xiaomi_cn_972783525_60l", + "DehumidifierFanLevel": "select.xiaomi_cn_972783525_60l_fan_level_p_2_4", + "DehumidifierUv": "switch.xiaomi_cn_972783525_60l_uv_p_2_10", + "FragranceSwitch": "switch.xiaomi_cn_1206553658_xw002_on_p_2_2", + "FragranceLight": "light.xiaomi_cn_1206553658_xw002_s_3_ambient_light", + "AmbientLight": "light.lemesh_cn_2041198316_wy0d02_s_2_light", + "Speaker": "media_player.xiaomi_cn_1221084723_l17a", + "SpeakerSleepMode": "switch.xiaomi_cn_1221084723_l17a_sleep_mode_p_7_3", + "Temperature": "sensor.mirsz_cn_2027039858_et71_temperature_p_3_1", + "Humidity": "sensor.mirsz_cn_2027039858_et71_relative_humidity_p_3_2", + "Pm25": "sensor.mirsz_cn_2027039858_et71_pm2_5_density_p_3_3", + "Co2": "sensor.mirsz_cn_2027039858_et71_co2_density_p_3_6", + "Formaldehyde": "sensor.mirsz_cn_2027039858_et71_hcho_density_p_3_7", + "Tvoc": "sensor.mirsz_cn_2027039858_et71_tvoc_density_p_3_8" + }, + "SleepScene": { + "TargetHumidity": 55, + "DehumidifierMode": "睡眠", + "FanLevel": "1挡", + "LightBrightnessPercent": 20, + "LightColorTemperatureKelvin": 2700, + "EnableFragrance": true, + "EnableSpeakerSleepMode": false, + "EnableUv": false + } + } +} diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantClient.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantClient.cs new file mode 100644 index 0000000..8aca8ad --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantClient.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Options; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; + +public sealed class HomeAssistantClient +{ + private readonly HttpClient _httpClient; + private readonly HomeAssistantOptions _options; + + public HomeAssistantClient(HttpClient httpClient, IOptions options) + { + _httpClient = httpClient; + _options = options.Value; + _httpClient.Timeout = TimeSpan.FromSeconds(Math.Clamp(_options.TimeoutSeconds, 3, 60)); + } + + public async Task> GetStatesAsync(CancellationToken cancellationToken = default) + { + using var request = CreateRequest(HttpMethod.Get, "api/states"); + using var response = await _httpClient.SendAsync(request, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + return await response.Content.ReadFromJsonAsync>(cancellationToken: cancellationToken) ?? []; + } + + public async Task CallServiceAsync( + string domain, + string service, + IReadOnlyDictionary data, + CancellationToken cancellationToken = default) + { + using var request = CreateRequest(HttpMethod.Post, $"api/services/{domain}/{service}"); + request.Content = JsonContent.Create(data); + using var response = await _httpClient.SendAsync(request, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + } + + private HttpRequestMessage CreateRequest(HttpMethod method, string path) + { + if (!_options.Enabled) + throw new InvalidOperationException("Home Assistant 接入未启用"); + if (string.IsNullOrWhiteSpace(_options.BaseUrl)) + throw new InvalidOperationException("Home Assistant 地址未配置"); + if (string.IsNullOrWhiteSpace(_options.AccessToken)) + throw new InvalidOperationException("Home Assistant 访问令牌未配置"); + + var baseUrl = _options.BaseUrl.TrimEnd('/') + "/"; + var request = new HttpRequestMessage(method, new Uri(new Uri(baseUrl), path)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.AccessToken); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + return request; + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) return; + var body = await response.Content.ReadAsStringAsync(cancellationToken); + var message = body.Length > 300 ? body[..300] : body; + throw new HttpRequestException($"Home Assistant 返回 {(int)response.StatusCode}: {message}"); + } +} diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantModels.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantModels.cs new file mode 100644 index 0000000..da5c613 --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantModels.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; + +public sealed class HomeAssistantState +{ + [JsonPropertyName("entity_id")] + public string EntityId { get; set; } = string.Empty; + + [JsonPropertyName("state")] + public string State { get; set; } = string.Empty; + + [JsonPropertyName("attributes")] + public JsonElement Attributes { get; set; } + + [JsonPropertyName("last_updated")] + public DateTimeOffset? LastUpdated { get; set; } + + public string? GetStringAttribute(string name) + { + if (!Attributes.TryGetProperty(name, out var value)) return null; + return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString(); + } +} + +public sealed class HomeAssistantActionResult +{ + public string Name { get; set; } = string.Empty; + public string EntityId { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} diff --git a/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs new file mode 100644 index 0000000..32c758d --- /dev/null +++ b/Evergreen.Health.Application/Health/Integrations/HomeAssistant/HomeAssistantOptions.cs @@ -0,0 +1,43 @@ +using Furion.ConfigurableOptions; + +namespace Evergreen.Health.Application.Health.Integrations.HomeAssistant; + +public sealed class HomeAssistantOptions : IConfigurableOptions +{ + public bool Enabled { get; set; } = true; + public string BaseUrl { get; set; } = string.Empty; + public string AccessToken { get; set; } = string.Empty; + public int TimeoutSeconds { get; set; } = 15; + public HomeAssistantEntityOptions Entities { get; set; } = new(); + public HomeAssistantSleepSceneOptions SleepScene { get; set; } = new(); +} + +public sealed class HomeAssistantEntityOptions +{ + public string Dehumidifier { get; set; } = string.Empty; + public string DehumidifierFanLevel { get; set; } = string.Empty; + public string DehumidifierUv { get; set; } = string.Empty; + public string FragranceSwitch { get; set; } = string.Empty; + public string FragranceLight { get; set; } = string.Empty; + public string AmbientLight { get; set; } = string.Empty; + public string Speaker { get; set; } = string.Empty; + public string SpeakerSleepMode { get; set; } = string.Empty; + public string Temperature { get; set; } = string.Empty; + public string Humidity { get; set; } = string.Empty; + public string Pm25 { get; set; } = string.Empty; + public string Co2 { get; set; } = string.Empty; + public string Formaldehyde { get; set; } = string.Empty; + public string Tvoc { get; set; } = string.Empty; +} + +public sealed class HomeAssistantSleepSceneOptions +{ + public int TargetHumidity { get; set; } = 55; + public string DehumidifierMode { get; set; } = "睡眠"; + public string FanLevel { get; set; } = "1挡"; + public int LightBrightnessPercent { get; set; } = 20; + public int LightColorTemperatureKelvin { get; set; } = 2700; + public bool EnableFragrance { get; set; } = true; + public bool EnableSpeakerSleepMode { get; set; } + public bool EnableUv { get; set; } +} diff --git a/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs b/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs new file mode 100644 index 0000000..0f36755 --- /dev/null +++ b/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs @@ -0,0 +1,41 @@ +namespace Evergreen.Health.Application.Health.Models; + +public sealed class HomeAssistantDeviceOutput +{ + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string EntityId { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public string? Value { get; set; } + public string? Unit { get; set; } + public bool IsAvailable { get; set; } + public DateTimeOffset? LastUpdated { get; set; } +} + +public sealed class HomeAssistantStatusOutput +{ + public bool Connected { get; set; } + public string Message { get; set; } = string.Empty; + public DateTimeOffset CheckedAt { get; set; } = DateTimeOffset.Now; + public List Devices { get; set; } = []; + public List Environment { get; set; } = []; +} + +public sealed class HomeAssistantSceneOutput +{ + public string SceneCode { get; set; } = string.Empty; + public string SceneName { get; set; } = string.Empty; + public bool Success { get; set; } + public string Status { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public DateTimeOffset ExecutedAt { get; set; } = DateTimeOffset.Now; + public List Actions { get; set; } = []; +} + +public sealed class HomeAssistantSceneActionOutput +{ + public string Name { get; set; } = string.Empty; + public string EntityId { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} diff --git a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs new file mode 100644 index 0000000..9b44cae --- /dev/null +++ b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs @@ -0,0 +1,279 @@ +using Evergreen.Health.Application.Health.Entities; +using Evergreen.Health.Application.Health.Integrations.HomeAssistant; +using Evergreen.Health.Application.Health.Models; +using Furion.DatabaseAccessor; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using SqlSugar; +using System.Security.Claims; +using System.Text.Json; + +namespace Evergreen.Health.Application.Health.Services; + +[ApiDescriptionSettings("健康管理", Name = "HealthHomeAssistant", Order = 51)] +public sealed class HealthHomeAssistantService : IDynamicApiController, ITransient +{ + private const string HealthUserIdClaim = "health_user_id"; + private readonly HomeAssistantClient _homeAssistant; + private readonly HomeAssistantOptions _options; + private readonly SqlSugarRepository _executionRepository; + private readonly IHttpContextAccessor _httpContextAccessor; + + public HealthHomeAssistantService( + HomeAssistantClient homeAssistant, + IOptions options, + SqlSugarRepository executionRepository, + IHttpContextAccessor httpContextAccessor) + { + _homeAssistant = homeAssistant; + _options = options.Value; + _executionRepository = executionRepository; + _httpContextAccessor = httpContextAccessor; + } + + [Authorize] + [HttpGet("/api/health/home-assistant/status")] + public async Task GetStatus(CancellationToken cancellationToken) + { + try + { + var states = await _homeAssistant.GetStatesAsync(cancellationToken); + var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase); + var entities = _options.Entities; + + return new HomeAssistantStatusOutput + { + Connected = true, + Message = "Home Assistant 已连接", + Devices = + [ + BuildDevice(stateMap, "dehumidifier", "除湿机", entities.Dehumidifier, "current_humidity", "%"), + BuildDevice(stateMap, "fragrance", "香氛机", entities.FragranceSwitch), + BuildDevice(stateMap, "ambient-light", "氛围灯", entities.AmbientLight), + BuildDevice(stateMap, "speaker", "Xiaomi Sound Pro", entities.Speaker), + ], + Environment = + [ + BuildDevice(stateMap, "temperature", "温度", entities.Temperature), + BuildDevice(stateMap, "humidity", "湿度", entities.Humidity), + BuildDevice(stateMap, "pm25", "PM2.5", entities.Pm25), + BuildDevice(stateMap, "co2", "CO₂", entities.Co2), + BuildDevice(stateMap, "formaldehyde", "甲醛", entities.Formaldehyde), + BuildDevice(stateMap, "tvoc", "TVOC", entities.Tvoc), + ] + }; + } + catch (Exception exception) + { + return new HomeAssistantStatusOutput + { + Connected = false, + Message = $"Home Assistant 连接失败:{exception.Message}" + }; + } + } + + [Authorize] + [HttpPost("/api/health/home-assistant/scenes/sleep")] + public Task ExecuteSleepScene(CancellationToken cancellationToken) + => ExecuteScene("sleep", "舒睡模式", ExecuteSleepActions, cancellationToken); + + [Authorize] + [HttpPost("/api/health/home-assistant/scenes/all-off")] + public Task ExecuteAllOffScene(CancellationToken cancellationToken) + => ExecuteScene("all-off", "一键关闭", ExecuteAllOffActions, cancellationToken); + + private async Task ExecuteScene( + string sceneCode, + string sceneName, + Func, List, CancellationToken, Task> executor, + CancellationToken cancellationToken) + { + var userId = GetCurrentUserId(); + var actions = new List(); + string status; + string message; + + try + { + var states = await _homeAssistant.GetStatesAsync(cancellationToken); + var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase); + await executor(stateMap, actions, cancellationToken); + + var successCount = actions.Count(item => item.Status == "Succeeded"); + var failedCount = actions.Count(item => item.Status == "Failed"); + var skippedCount = actions.Count(item => item.Status == "Skipped"); + status = failedCount == 0 && successCount > 0 ? "Succeeded" : successCount > 0 ? "Partial" : "Failed"; + message = status switch + { + "Succeeded" when skippedCount > 0 => $"场景已执行,{successCount} 项成功,{skippedCount} 项因设备离线或未配置而跳过", + "Succeeded" => $"场景已执行,{successCount} 项成功", + "Partial" => $"场景部分执行成功:{successCount} 项成功,{failedCount} 项失败,{skippedCount} 项跳过", + _ => "当前没有可执行的在线设备" + }; + } + catch (Exception exception) + { + status = "Failed"; + message = $"场景执行失败:{exception.Message}"; + } + + await _executionRepository.InsertAsync(new HealthSceneExecution + { + UserId = userId, + SceneCode = sceneCode, + SceneName = sceneName, + Status = status, + Detail = JsonSerializer.Serialize(new { message, actions }) + }); + + return new HomeAssistantSceneOutput + { + SceneCode = sceneCode, + SceneName = sceneName, + Success = status is "Succeeded" or "Partial", + Status = status, + Message = message, + Actions = actions.Select(item => new HomeAssistantSceneActionOutput + { + Name = item.Name, + EntityId = item.EntityId, + Status = item.Status, + Message = item.Message + }).ToList() + }; + } + + private async Task ExecuteSleepActions( + IReadOnlyDictionary states, + List results, + CancellationToken cancellationToken) + { + var entities = _options.Entities; + var scene = _options.SleepScene; + + await RunAction(states, results, "开启除湿机", entities.Dehumidifier, "humidifier", "turn_on", null, cancellationToken); + await RunAction(states, results, "设置目标湿度", entities.Dehumidifier, "humidifier", "set_humidity", + new Dictionary { ["humidity"] = scene.TargetHumidity }, cancellationToken); + await RunAction(states, results, "切换睡眠模式", entities.Dehumidifier, "humidifier", "set_mode", + new Dictionary { ["mode"] = scene.DehumidifierMode }, cancellationToken); + await RunAction(states, results, "设置低档风速", entities.DehumidifierFanLevel, "select", "select_option", + new Dictionary { ["option"] = scene.FanLevel }, cancellationToken); + await RunAction(states, results, scene.EnableUv ? "开启 UV 杀菌" : "关闭 UV 杀菌", entities.DehumidifierUv, + "switch", scene.EnableUv ? "turn_on" : "turn_off", null, cancellationToken); + + if (scene.EnableFragrance) + { + await RunAction(states, results, "开启香氛机", entities.FragranceSwitch, "switch", "turn_on", null, cancellationToken); + await RunAction(states, results, "开启香氛灯", entities.FragranceLight, "light", "turn_on", + new Dictionary { ["brightness_pct"] = scene.LightBrightnessPercent }, cancellationToken); + } + + await RunAction(states, results, "开启助眠灯光", entities.AmbientLight, "light", "turn_on", + new Dictionary + { + ["brightness_pct"] = scene.LightBrightnessPercent, + ["color_temp_kelvin"] = scene.LightColorTemperatureKelvin + }, cancellationToken); + + if (scene.EnableSpeakerSleepMode) + await RunAction(states, results, "开启音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_on", null, cancellationToken); + } + + private async Task ExecuteAllOffActions( + IReadOnlyDictionary states, + List results, + CancellationToken cancellationToken) + { + var entities = _options.Entities; + await RunAction(states, results, "关闭除湿机", entities.Dehumidifier, "humidifier", "turn_off", null, cancellationToken); + await RunAction(states, results, "关闭香氛机", entities.FragranceSwitch, "switch", "turn_off", null, cancellationToken); + await RunAction(states, results, "关闭香氛灯", entities.FragranceLight, "light", "turn_off", null, cancellationToken); + await RunAction(states, results, "关闭助眠灯光", entities.AmbientLight, "light", "turn_off", null, cancellationToken); + await RunAction(states, results, "关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", null, cancellationToken); + } + + private async Task RunAction( + IReadOnlyDictionary states, + List results, + string name, + string entityId, + string domain, + string service, + IReadOnlyDictionary? extraData, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(entityId) || !states.TryGetValue(entityId, out var state)) + { + results.Add(new HomeAssistantActionResult { Name = name, EntityId = entityId, Status = "Skipped", Message = "实体未配置或不存在" }); + return; + } + + if (!IsAvailable(state)) + { + results.Add(new HomeAssistantActionResult { Name = name, EntityId = entityId, Status = "Skipped", Message = "设备当前离线" }); + return; + } + + var data = new Dictionary { ["entity_id"] = entityId }; + if (extraData is not null) + { + foreach (var item in extraData) data[item.Key] = item.Value; + } + + try + { + await _homeAssistant.CallServiceAsync(domain, service, data, cancellationToken); + results.Add(new HomeAssistantActionResult { Name = name, EntityId = entityId, Status = "Succeeded", Message = "执行成功" }); + } + catch (Exception exception) + { + results.Add(new HomeAssistantActionResult { Name = name, EntityId = entityId, Status = "Failed", Message = exception.Message }); + } + } + + private long GetCurrentUserId() + { + var value = _httpContextAccessor.HttpContext?.User.FindFirstValue(HealthUserIdClaim); + if (!long.TryParse(value, out var userId)) + throw new UnauthorizedAccessException("登录状态已失效"); + return userId; + } + + private static HomeAssistantDeviceOutput BuildDevice( + IReadOnlyDictionary states, + string code, + string name, + string entityId, + string? attributeName = null, + string? attributeUnit = null) + { + if (string.IsNullOrWhiteSpace(entityId) || !states.TryGetValue(entityId, out var state)) + { + return new HomeAssistantDeviceOutput + { + Code = code, + Name = name, + EntityId = entityId, + State = "not_found", + IsAvailable = false + }; + } + + var value = attributeName is null ? state.State : state.GetStringAttribute(attributeName); + return new HomeAssistantDeviceOutput + { + Code = code, + Name = name, + EntityId = entityId, + State = state.State, + Value = value, + Unit = attributeUnit ?? state.GetStringAttribute("unit_of_measurement"), + IsAvailable = IsAvailable(state), + LastUpdated = state.LastUpdated + }; + } + + private static bool IsAvailable(HomeAssistantState state) + => state.State is not ("unavailable" or "unknown"); +}