This commit is contained in:
fuenmao
2026-09-21 16:52:27 +08:00
parent 54d5c4fda3
commit 8c922a3c7f
8 changed files with 496 additions and 0 deletions
+2
View File
@@ -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<CDConfigOptions>();
services.AddConfigurableOptions<DeepSeekOptions>();
services.AddConfigurableOptions<LocalizationSettingsOptions>();
services.AddConfigurableOptions<HomeAssistantOptions>();
services.Configure<IpRateLimitOptions>(App.Configuration.GetSection("IpRateLimiting"));
services.Configure<IpRateLimitPolicies>(App.Configuration.GetSection("IpRateLimitPolicies"));
services.Configure<ClientRateLimitOptions>(App.Configuration.GetSection("ClientRateLimiting"));
+2
View File
@@ -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<HomeAssistantClient>();
// 任务队列
services.AddTaskQueue();
// 脱敏检测
@@ -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
}
}
}
@@ -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<HomeAssistantOptions> options)
{
_httpClient = httpClient;
_options = options.Value;
_httpClient.Timeout = TimeSpan.FromSeconds(Math.Clamp(_options.TimeoutSeconds, 3, 60));
}
public async Task<IReadOnlyList<HomeAssistantState>> 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<List<HomeAssistantState>>(cancellationToken: cancellationToken) ?? [];
}
public async Task CallServiceAsync(
string domain,
string service,
IReadOnlyDictionary<string, object?> 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}");
}
}
@@ -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;
}
@@ -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; }
}
@@ -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<HomeAssistantDeviceOutput> Devices { get; set; } = [];
public List<HomeAssistantDeviceOutput> 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<HomeAssistantSceneActionOutput> 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;
}
@@ -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<HealthSceneExecution> _executionRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
public HealthHomeAssistantService(
HomeAssistantClient homeAssistant,
IOptions<HomeAssistantOptions> options,
SqlSugarRepository<HealthSceneExecution> executionRepository,
IHttpContextAccessor httpContextAccessor)
{
_homeAssistant = homeAssistant;
_options = options.Value;
_executionRepository = executionRepository;
_httpContextAccessor = httpContextAccessor;
}
[Authorize]
[HttpGet("/api/health/home-assistant/status")]
public async Task<HomeAssistantStatusOutput> 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<HomeAssistantSceneOutput> ExecuteSleepScene(CancellationToken cancellationToken)
=> ExecuteScene("sleep", "舒睡模式", ExecuteSleepActions, cancellationToken);
[Authorize]
[HttpPost("/api/health/home-assistant/scenes/all-off")]
public Task<HomeAssistantSceneOutput> ExecuteAllOffScene(CancellationToken cancellationToken)
=> ExecuteScene("all-off", "一键关闭", ExecuteAllOffActions, cancellationToken);
private async Task<HomeAssistantSceneOutput> ExecuteScene(
string sceneCode,
string sceneName,
Func<IReadOnlyDictionary<string, HomeAssistantState>, List<HomeAssistantActionResult>, CancellationToken, Task> executor,
CancellationToken cancellationToken)
{
var userId = GetCurrentUserId();
var actions = new List<HomeAssistantActionResult>();
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<string, HomeAssistantState> states,
List<HomeAssistantActionResult> 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<string, object?> { ["humidity"] = scene.TargetHumidity }, cancellationToken);
await RunAction(states, results, "切换睡眠模式", entities.Dehumidifier, "humidifier", "set_mode",
new Dictionary<string, object?> { ["mode"] = scene.DehumidifierMode }, cancellationToken);
await RunAction(states, results, "设置低档风速", entities.DehumidifierFanLevel, "select", "select_option",
new Dictionary<string, object?> { ["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<string, object?> { ["brightness_pct"] = scene.LightBrightnessPercent }, cancellationToken);
}
await RunAction(states, results, "开启助眠灯光", entities.AmbientLight, "light", "turn_on",
new Dictionary<string, object?>
{
["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<string, HomeAssistantState> states,
List<HomeAssistantActionResult> 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<string, HomeAssistantState> states,
List<HomeAssistantActionResult> results,
string name,
string entityId,
string domain,
string service,
IReadOnlyDictionary<string, object?>? 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<string, object?> { ["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<string, HomeAssistantState> 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");
}