From c1c86b56b63e2bda0d37490e39a99b2c7ab8f77e Mon Sep 17 00:00:00 2001 From: fuenmao <980740792@qq.com> Date: Tue, 22 Sep 2026 15:01:29 +0800 Subject: [PATCH] 111 --- .../Health/Entities/HealthEntities.cs | 6 + .../Health/Models/HealthModels.cs | 62 +++ .../Health/Models/HomeAssistantModels.cs | 25 ++ .../Health/Services/HealthAppService.cs | 334 +++++++++++++- .../Services/HealthHomeAssistantService.cs | 415 ++++++++++++++++-- 5 files changed, 797 insertions(+), 45 deletions(-) diff --git a/Evergreen.Health.Application/Health/Entities/HealthEntities.cs b/Evergreen.Health.Application/Health/Entities/HealthEntities.cs index 97f64be..656e039 100644 --- a/Evergreen.Health.Application/Health/Entities/HealthEntities.cs +++ b/Evergreen.Health.Application/Health/Entities/HealthEntities.cs @@ -62,6 +62,12 @@ public sealed class HealthProfile : EntityBase [SugarColumn(ColumnDescription = "健康目标", Length = 500, IsNullable = true)] public string? HealthGoal { get; set; } + + [SugarColumn(ColumnDescription = "健康评估问卷答案JSON", ColumnDataType = "text", IsNullable = true)] + public string? AssessmentJson { get; set; } + + [SugarColumn(ColumnDescription = "个性化智能设备启动参数JSON", ColumnDataType = "text", IsNullable = true)] + public string? CustomSceneConfigJson { get; set; } } [SugarTable("health_module_selection", "用户健康板块选择")] diff --git a/Evergreen.Health.Application/Health/Models/HealthModels.cs b/Evergreen.Health.Application/Health/Models/HealthModels.cs index 03b32ae..98d22b8 100644 --- a/Evergreen.Health.Application/Health/Models/HealthModels.cs +++ b/Evergreen.Health.Application/Health/Models/HealthModels.cs @@ -94,4 +94,66 @@ public sealed class HealthDashboardOutput public SaveHealthProfileInput? Profile { get; set; } public List SelectedModules { get; set; } = []; public HealthPlanOutput? CurrentPlan { get; set; } + public bool HasCompletedAssessment { get; set; } + public decimal? Bmi { get; set; } + public string BmiStatus { get; set; } = string.Empty; + public PersonalizedSceneConfig? SceneConfig { get; set; } +} + +public sealed class PersonalizedSceneConfig +{ + public int TargetHumidity { get; set; } = 55; + public string DehumidifierMode { get; set; } = "睡眠"; + public string FanLevel { get; set; } = "1档"; + public bool EnableUv { get; set; } = false; + + public int LightBrightnessPercent { get; set; } = 20; + public int LightColorTemperatureKelvin { get; set; } = 2700; + + public decimal MattressTargetTemp { get; set; } = 36m; + public int MattressLeftFirmness { get; set; } = 5; + public int MattressRightFirmness { get; set; } = 6; + public int MattressHeatingLevel { get; set; } = 1; + public string MattressMode { get; set; } = "智能适配"; + + public bool EnableFragrance { get; set; } = true; + public int FragranceLevel { get; set; } = 1; + + public bool EnableSpeakerSleepMode { get; set; } = true; + public string SpeakerAudioMode { get; set; } = "助眠白噪音"; + public int SpeakerVolumePercent { get; set; } = 15; +} + +public sealed class HealthAssessmentAnswers +{ + public string SleepDuration { get; set; } = string.Empty; + public string FallingAsleepSpeed { get; set; } = string.Empty; + public string TemperaturePreference { get; set; } = string.Empty; + public string NightAwakening { get; set; } = string.Empty; + public string MattressPreference { get; set; } = string.Empty; + + public string ExerciseFrequency { get; set; } = string.Empty; + public string MuscleFatigueArea { get; set; } = string.Empty; + public string ActiveTimeOfDay { get; set; } = string.Empty; + + public string MealRegularity { get; set; } = string.Empty; + public string FlavorPreference { get; set; } = string.Empty; + public string LateNightEating { get; set; } = string.Empty; + public string DailyWaterIntake { get; set; } = string.Empty; +} + +public sealed class SaveHealthAssessmentInput +{ + public SaveHealthProfileInput BasicInfo { get; set; } = new(); + public HealthAssessmentAnswers Answers { get; set; } = new(); +} + +public sealed class HealthAssessmentOutput +{ + public bool HasCompletedAssessment { get; set; } + public SaveHealthProfileInput BasicInfo { get; set; } = new(); + public HealthAssessmentAnswers Answers { get; set; } = new(); + public PersonalizedSceneConfig SceneConfig { get; set; } = new(); + public decimal? Bmi { get; set; } + public string BmiStatus { get; set; } = "正常"; } diff --git a/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs b/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs index 5d826cb..b82a66a 100644 --- a/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs +++ b/Evergreen.Health.Application/Health/Models/HomeAssistantModels.cs @@ -73,6 +73,15 @@ public sealed class HomeAssistantStatusOutput public HomeAssistantSceneDevicesSummaryOutput SceneDevices { get; set; } = new(); public HomeAssistantEnvironmentOutput Environment { get; set; } = new(); public List ScenePresets { get; set; } = []; + public Dictionary ControlParameters { get; set; } = []; +} + +public sealed class HomeAssistantParameterStateOutput +{ + public string EntityId { get; set; } = string.Empty; + public string State { get; set; } = string.Empty; + public bool Available { get; set; } + public Dictionary? Attributes { get; set; } } public sealed class HomeAssistantScenePresetItem @@ -103,6 +112,22 @@ public sealed class HomeAssistantDeviceSwitchOutput public string Message { get; set; } = string.Empty; } +public sealed class HomeAssistantSetParameterInput +{ + public string DeviceId { get; set; } = string.Empty; + public string? EntityId { get; set; } + public string ParameterType { get; set; } = string.Empty; + public object? Value { get; set; } +} + +public sealed class HomeAssistantSetParameterOutput +{ + public bool Success { get; set; } + public string DeviceId { get; set; } = string.Empty; + public string ParameterType { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; +} + public sealed class HomeAssistantSceneOutput { public string SceneCode { get; set; } = string.Empty; diff --git a/Evergreen.Health.Application/Health/Services/HealthAppService.cs b/Evergreen.Health.Application/Health/Services/HealthAppService.cs index 66aadd3..70c81c7 100644 --- a/Evergreen.Health.Application/Health/Services/HealthAppService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthAppService.cs @@ -104,15 +104,151 @@ public sealed class HealthAppService : IDynamicApiController, ITransient .OrderByDescending(item => item.Version) .FirstAsync(); + var hasCompletedAssessment = profile != null && !string.IsNullOrWhiteSpace(profile.AssessmentJson); + decimal? bmi = null; + var bmiStatus = string.Empty; + if (profile?.HeightCm > 0 && profile.WeightKg > 0) + { + var h = profile.HeightCm.Value / 100m; + bmi = Math.Round(profile.WeightKg.Value / (h * h), 1); + bmiStatus = bmi < 18.5m ? "偏瘦" : bmi <= 23.9m ? "正常" : bmi <= 27.9m ? "超重" : "肥胖"; + } + + PersonalizedSceneConfig? sceneConfig = null; + if (!string.IsNullOrWhiteSpace(profile?.CustomSceneConfigJson)) + { + try + { + sceneConfig = JsonSerializer.Deserialize(profile.CustomSceneConfigJson); + } + catch + { + } + } + if (sceneConfig == null && hasCompletedAssessment) + { + var answers = JsonSerializer.Deserialize(profile!.AssessmentJson!) ?? new HealthAssessmentAnswers(); + sceneConfig = CalculatePersonalizedSceneConfig(answers, MapProfile(profile)); + } + return new HealthDashboardOutput { User = MapUser(user), Profile = profile is null ? null : MapProfile(profile), SelectedModules = modules, - CurrentPlan = plan is null ? null : MapPlan(plan) + CurrentPlan = plan is null ? null : MapPlan(plan), + HasCompletedAssessment = hasCompletedAssessment, + Bmi = bmi, + BmiStatus = bmiStatus, + SceneConfig = sceneConfig }; } + [Authorize] + [HttpGet("/api/health/assessment")] + public async Task GetAssessment() + { + var userId = GetCurrentUserId(); + var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); + if (profile == null) + { + return new HealthAssessmentOutput + { + HasCompletedAssessment = false, + BasicInfo = new SaveHealthProfileInput(), + Answers = new HealthAssessmentAnswers(), + SceneConfig = new PersonalizedSceneConfig() + }; + } + + var answers = string.IsNullOrWhiteSpace(profile.AssessmentJson) + ? new HealthAssessmentAnswers() + : JsonSerializer.Deserialize(profile.AssessmentJson) ?? new HealthAssessmentAnswers(); + + var hasCompleted = !string.IsNullOrWhiteSpace(profile.AssessmentJson); + var sceneConfig = string.IsNullOrWhiteSpace(profile.CustomSceneConfigJson) + ? CalculatePersonalizedSceneConfig(answers, MapProfile(profile)) + : JsonSerializer.Deserialize(profile.CustomSceneConfigJson) ?? new PersonalizedSceneConfig(); + + decimal? bmi = null; + var bmiStatus = "正常"; + if (profile.HeightCm > 0 && profile.WeightKg > 0) + { + var h = profile.HeightCm.Value / 100m; + bmi = Math.Round(profile.WeightKg.Value / (h * h), 1); + bmiStatus = bmi < 18.5m ? "偏瘦" : bmi <= 23.9m ? "正常" : bmi <= 27.9m ? "超重" : "肥胖"; + } + + return new HealthAssessmentOutput + { + HasCompletedAssessment = hasCompleted, + BasicInfo = MapProfile(profile), + Answers = answers, + SceneConfig = sceneConfig, + Bmi = bmi, + BmiStatus = bmiStatus + }; + } + + [Authorize] + [HttpPost("/api/health/assessment")] + [UnitOfWork] + public async Task SaveAssessment(SaveHealthAssessmentInput input) + { + ValidateAssessment(input); + var userId = GetCurrentUserId(); + var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); + var calculatedConfig = CalculatePersonalizedSceneConfig(input.Answers, input.BasicInfo); + + if (profile == null) + { + profile = new HealthProfile { UserId = userId }; + ApplyProfile(profile, input.BasicInfo); + profile.AssessmentJson = JsonSerializer.Serialize(input.Answers); + profile.CustomSceneConfigJson = JsonSerializer.Serialize(calculatedConfig); + await _profileRepository.InsertAsync(profile); + } + else + { + ApplyProfile(profile, input.BasicInfo); + profile.AssessmentJson = JsonSerializer.Serialize(input.Answers); + profile.CustomSceneConfigJson = JsonSerializer.Serialize(calculatedConfig); + await _profileRepository.UpdateAsync(profile); + } + + var modules = new List { "sleep", "exercise", "diet" }; + var existingModules = await _moduleRepository.AsQueryable().Where(m => m.UserId == userId).ToListAsync(); + foreach (var mod in modules) + { + var existing = existingModules.FirstOrDefault(m => m.ModuleCode == mod); + if (existing == null) + { + await _moduleRepository.InsertAsync(new HealthModuleSelection { UserId = userId, ModuleCode = mod, IsEnabled = true }); + } + else if (!existing.IsEnabled) + { + existing.IsEnabled = true; + await _moduleRepository.UpdateAsync(existing); + } + } + + var maxVersion = await _planRepository.AsQueryable().Where(p => p.UserId == userId).MaxAsync(p => (int?)p.Version) ?? 0; + await _planRepository.UpdateAsync(p => new HealthPlan { IsCurrent = false }, p => p.UserId == userId && p.IsCurrent); + var planItems = BuildPlanItems(profile, modules); + var plan = new HealthPlan + { + UserId = userId, + Version = maxVersion + 1, + Title = $"个性化健康方案 v{maxVersion + 1}.0", + Summary = BuildAssessmentPlanSummary(profile, input.Answers), + ParametersJson = JsonSerializer.Serialize(planItems), + IsCurrent = true + }; + await _planRepository.InsertAsync(plan); + + return await GetAssessment(); + } + [Authorize] [HttpPut("/api/health/profile")] public async Task SaveProfile(SaveHealthProfileInput input) @@ -328,4 +464,200 @@ public sealed class HealthAppService : IDynamicApiController, ITransient User = MapUser(user) }; } + + private static void ValidateAssessment(SaveHealthAssessmentInput input) + { + var info = input.BasicInfo ?? throw Oops.Oh("请填写个人基本信息"); + var answers = input.Answers ?? throw Oops.Oh("请完成健康评估问卷"); + + if (info.Gender is not ("男" or "女")) throw Oops.Oh("请选择性别"); + if (info.Birthday is null) throw Oops.Oh("请填写年龄"); + var age = DateTime.Today.Year - info.Birthday.Value.Year; + if (info.Birthday.Value.Date > DateTime.Today.AddYears(-age)) age--; + if (age is < 1 or > 120) throw Oops.Oh("年龄应在 1 到 120 岁之间"); + if (info.HeightCm is null or < 80 or > 250) throw Oops.Oh("身高应在 80 到 250cm 之间"); + if (info.WeightKg is null or < 20 or > 300) throw Oops.Oh("体重应在 20 到 300kg 之间"); + + RequireChoice(answers.SleepDuration, "每晚平均睡眠时长", "<6", "6-7", "7-8", ">8"); + RequireChoice(answers.FallingAsleepSpeed, "入睡耗时", "hard", "normal", "fast"); + RequireChoice(answers.TemperaturePreference, "温度偏好", "heat_fear", "cold_fear", "normal"); + RequireChoice(answers.NightAwakening, "夜醒情况", "frequent", "light", "none"); + RequireChoice(answers.MattressPreference, "床垫偏好", "firm", "medium", "soft"); + RequireChoice(answers.ExerciseFrequency, "运动频率", "none", "1-2", "3-5", "daily"); + RequireChoice(answers.MuscleFatigueArea, "疲劳部位", "back_neck", "waist_back", "legs", "none"); + RequireChoice(answers.ActiveTimeOfDay, "活跃时段", "morning", "evening", "night", "irregular"); + RequireChoice(answers.MealRegularity, "三餐规律", "regular", "skip_breakfast", "irregular"); + RequireChoice(answers.FlavorPreference, "口味偏好", "light", "moderate", "heavy"); + RequireChoice(answers.LateNightEating, "夜宵频率", "often", "sometimes", "rarely"); + RequireChoice(answers.DailyWaterIntake, "饮水情况", "abundant", "moderate", "scarce"); + } + + private static void RequireChoice(string value, string label, params string[] allowed) + { + if (string.IsNullOrWhiteSpace(value) || !allowed.Contains(value, StringComparer.Ordinal)) + throw Oops.Oh($"请完成“{label}”问题"); + } + + private static PersonalizedSceneConfig CalculatePersonalizedSceneConfig(HealthAssessmentAnswers answers, SaveHealthProfileInput basicInfo) + { + var config = new PersonalizedSceneConfig(); + + switch (answers.TemperaturePreference) + { + case "heat_fear": + config.TargetHumidity = 48; + config.FanLevel = "2挡"; + config.MattressTargetTemp = 32m; + config.MattressHeatingLevel = 1; + break; + case "cold_fear": + config.TargetHumidity = 58; + config.FanLevel = "1挡"; + config.MattressTargetTemp = 37.5m; + config.MattressHeatingLevel = 3; + break; + default: + config.TargetHumidity = 52; + config.FanLevel = "1挡"; + config.MattressTargetTemp = 35.5m; + config.MattressHeatingLevel = 2; + break; + } + + switch (answers.FallingAsleepSpeed) + { + case "hard": + config.LightBrightnessPercent = 8; + config.LightColorTemperatureKelvin = 2200; + config.FragranceLevel = 2; + config.EnableSpeakerSleepMode = true; + config.SpeakerAudioMode = "助眠白噪音"; + config.SpeakerVolumePercent = 12; + break; + case "fast": + config.LightBrightnessPercent = 25; + config.LightColorTemperatureKelvin = 3000; + config.EnableSpeakerSleepMode = false; + config.SpeakerAudioMode = "关闭助眠音频"; + config.SpeakerVolumePercent = 10; + break; + default: + config.LightBrightnessPercent = 18; + config.LightColorTemperatureKelvin = 2700; + config.FragranceLevel = 1; + config.EnableSpeakerSleepMode = true; + config.SpeakerAudioMode = "舒缓轻音乐"; + config.SpeakerVolumePercent = 15; + break; + } + + switch (answers.SleepDuration) + { + case "<6": + config.LightBrightnessPercent -= 3; + config.SpeakerVolumePercent += 3; + config.FragranceLevel++; + break; + case "6-7": + config.LightBrightnessPercent -= 1; + config.SpeakerVolumePercent += 1; + break; + case ">8": + config.LightBrightnessPercent += 2; + config.SpeakerVolumePercent -= 2; + break; + } + + if (answers.MuscleFatigueArea is "waist_back" or "back_neck" || answers.MattressPreference == "firm") + { + config.MattressLeftFirmness = 7; + config.MattressRightFirmness = 7; + config.MattressMode = "智能腰托"; + } + else if (answers.MattressPreference == "soft") + { + config.MattressLeftFirmness = 4; + config.MattressRightFirmness = 4; + config.MattressMode = "瑜伽助眠"; + } + else + { + config.MattressLeftFirmness = answers.MuscleFatigueArea == "legs" ? 6 : 5; + config.MattressRightFirmness = answers.MuscleFatigueArea == "legs" ? 6 : 5; + config.MattressMode = "智能适配"; + } + + config.DehumidifierMode = answers.NightAwakening == "none" ? "智能" : "睡眠"; + if (answers.NightAwakening == "frequent") + { + config.FanLevel = "1挡"; + config.LightBrightnessPercent -= 2; + } + + switch (answers.ExerciseFrequency) + { + case "none": config.MattressHeatingLevel++; break; + case "daily": config.MattressHeatingLevel--; break; + } + + switch (answers.ActiveTimeOfDay) + { + case "morning": config.LightColorTemperatureKelvin += 150; break; + case "night": + config.LightColorTemperatureKelvin -= 250; + config.LightBrightnessPercent -= 2; + break; + case "irregular": + config.EnableSpeakerSleepMode = true; + config.SpeakerVolumePercent += 2; + break; + } + + config.EnableUv = answers.LateNightEating is "often" or "sometimes" || answers.FlavorPreference == "heavy"; + if (answers.MealRegularity == "irregular") + { + config.EnableUv = true; + config.FragranceLevel++; + } + else if (answers.MealRegularity == "skip_breakfast") + { + config.FragranceLevel++; + } + + if (answers.DailyWaterIntake == "scarce") config.TargetHumidity += 3; + if (answers.DailyWaterIntake == "abundant") config.TargetHumidity -= 2; + + if (basicInfo.HeightCm > 0 && basicInfo.WeightKg > 0) + { + var height = basicInfo.HeightCm.Value / 100m; + var bmi = basicInfo.WeightKg.Value / (height * height); + if (bmi < 18.5m) config.MattressHeatingLevel++; + if (bmi >= 28m) config.TargetHumidity -= 2; + } + if (basicInfo.Birthday.HasValue) + { + var age = DateTime.Today.Year - basicInfo.Birthday.Value.Year; + if (age >= 60) + { + config.LightBrightnessPercent += 3; + config.SpeakerVolumePercent = Math.Max(8, config.SpeakerVolumePercent - 2); + } + } + + config.TargetHumidity = Math.Clamp(config.TargetHumidity, 40, 70); + config.LightBrightnessPercent = Math.Clamp(config.LightBrightnessPercent, 1, 100); + config.LightColorTemperatureKelvin = Math.Clamp(config.LightColorTemperatureKelvin, 2200, 5000); + config.SpeakerVolumePercent = Math.Clamp(config.SpeakerVolumePercent, 1, 50); + config.FragranceLevel = Math.Clamp(config.FragranceLevel, 1, 3); + config.MattressHeatingLevel = Math.Clamp(config.MattressHeatingLevel, 0, 4); + return config; + } + + private static string BuildAssessmentPlanSummary(HealthProfile profile, HealthAssessmentAnswers answers) + { + var sleepNote = answers.FallingAsleepSpeed == "hard" ? "针对入睡耗时较长启用了音箱睡眠模式与极暗暖光;" : ""; + var tempNote = answers.TemperaturePreference == "heat_fear" ? "依据体感偏热降低了床垫加热档位与室内湿度;" : answers.TemperaturePreference == "cold_fear" ? "依据体感偏冷提高了床垫保温档位;" : ""; + var supportNote = answers.MuscleFatigueArea is "waist_back" or "back_neck" ? "针对腰背与颈部疲劳启用了床垫智能腰托模式;" : ""; + return $"已完成全面健康评估,{sleepNote}{tempNote}{supportNote}全套智能家具启动参数已完成个性化配置。"; + } } diff --git a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs index 35d4cd8..9b888a2 100644 --- a/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs +++ b/Evergreen.Health.Application/Health/Services/HealthHomeAssistantService.cs @@ -18,6 +18,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie private readonly HomeAssistantRegistryService _registryService; private readonly HomeAssistantOptions _options; private readonly SqlSugarRepository _executionRepository; + private readonly SqlSugarRepository _profileRepository; private readonly IHttpContextAccessor _httpContextAccessor; public HealthHomeAssistantService( @@ -25,12 +26,14 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie HomeAssistantRegistryService registryService, IOptions options, SqlSugarRepository executionRepository, + SqlSugarRepository profileRepository, IHttpContextAccessor httpContextAccessor) { _homeAssistant = homeAssistant; _registryService = registryService; _options = options.Value; _executionRepository = executionRepository; + _profileRepository = profileRepository; _httpContextAccessor = httpContextAccessor; } @@ -48,7 +51,22 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie var summary = BuildSummary(discoveredDevices); var sceneDevices = BuildSceneDevicesSummary(stateMap); var environment = BuildEnvironmentOutput(stateMap, discoveredDevices); - var scenePresets = BuildScenePresets(stateMap); + + PersonalizedSceneConfig? userConfig = null; + try + { + var userId = GetCurrentUserId(); + var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); + if (!string.IsNullOrWhiteSpace(profile?.CustomSceneConfigJson)) + { + userConfig = JsonSerializer.Deserialize(profile.CustomSceneConfigJson); + } + } + catch + { + } + + var scenePresets = BuildScenePresets(stateMap, userConfig); return new HomeAssistantStatusOutput { @@ -59,7 +77,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie Devices = discoveredDevices, SceneDevices = sceneDevices, Environment = environment, - ScenePresets = scenePresets + ScenePresets = scenePresets, + ControlParameters = BuildControlParameterStates(stateMap) }; } catch (Exception exception) @@ -73,7 +92,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie Devices = [], SceneDevices = BuildSceneDevicesSummary(new Dictionary(StringComparer.OrdinalIgnoreCase)), Environment = new HomeAssistantEnvironmentOutput(), - ScenePresets = BuildScenePresets(new Dictionary(StringComparer.OrdinalIgnoreCase)) + ScenePresets = BuildScenePresets(new Dictionary(StringComparer.OrdinalIgnoreCase)), + ControlParameters = [] }; } } @@ -190,10 +210,220 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie } } + [Authorize] + [HttpPost("/api/health/home-assistant/devices/set-parameter")] + public async Task SetParameter( + [FromBody] HomeAssistantSetParameterInput input, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(input.DeviceId) || string.IsNullOrWhiteSpace(input.ParameterType)) + { + return new HomeAssistantSetParameterOutput + { + Success = false, + DeviceId = input.DeviceId, + ParameterType = input.ParameterType, + Message = "未指定控制设备或参数" + }; + } + + try + { + var states = await _homeAssistant.GetStatesAsync(cancellationToken); + var stateMap = states.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase); + var registries = await _registryService.GetRegistriesAsync(cancellationToken); + var discovered = BuildDiscoveredDevices(registries, stateMap); + var targetDevice = discovered.FirstOrDefault(d => + string.Equals(d.DeviceId, input.DeviceId, StringComparison.OrdinalIgnoreCase)); + if (targetDevice is null) + { + return new HomeAssistantSetParameterOutput + { + Success = false, + DeviceId = input.DeviceId, + ParameterType = input.ParameterType, + Message = "未找到指定设备" + }; + } + + var parameterType = input.ParameterType.Trim().ToLowerInvariant(); + string targetEntityId; + string domain; + string service; + var serviceData = new Dictionary(); + switch (parameterType) + { + case "humidity": + targetEntityId = _options.Entities.Dehumidifier; + domain = "humidifier"; + service = "set_humidity"; + serviceData["humidity"] = ReadInt(input.Value, 40, 70, "目标湿度"); + break; + case "fan_level": + targetEntityId = _options.Entities.DehumidifierFanLevel; + domain = "select"; + service = "select_option"; + var fanLevel = ReadString(input.Value).Replace('档', '挡'); + if (fanLevel is not ("1挡" or "2挡" or "3挡")) throw new InvalidOperationException("风速档位无效"); + serviceData["option"] = fanLevel; + break; + case "dehumidifier_mode": + targetEntityId = _options.Entities.Dehumidifier; + domain = "humidifier"; + service = "set_mode"; + var dehumidifierMode = ReadString(input.Value); + if (dehumidifierMode is not ("睡眠" or "智能")) throw new InvalidOperationException("除湿模式无效"); + serviceData["mode"] = dehumidifierMode; + break; + case "brightness": + targetEntityId = _options.Entities.AmbientLight; + domain = "light"; + service = "turn_on"; + serviceData["brightness_pct"] = ReadInt(input.Value, 1, 100, "灯光亮度"); + break; + case "color_temp": + targetEntityId = _options.Entities.AmbientLight; + domain = "light"; + service = "turn_on"; + serviceData["color_temp_kelvin"] = ReadInt(input.Value, 2200, 5000, "灯光色温"); + break; + case "volume": + targetEntityId = _options.Entities.Speaker; + domain = "media_player"; + service = "volume_set"; + serviceData["volume_level"] = ReadInt(input.Value, 0, 100, "音箱音量") / 100d; + break; + case "speaker_sleep_mode": + targetEntityId = _options.Entities.SpeakerSleepMode; + domain = "switch"; + service = ReadBool(input.Value) ? "turn_on" : "turn_off"; + break; + case "mattress_left_heating": + targetEntityId = _options.Entities.MattressLeftHeatingLevel; + domain = "number"; + service = "set_value"; + serviceData["value"] = ReadInt(input.Value, 0, 4, "床垫左侧加热档位"); + break; + case "mattress_right_heating": + targetEntityId = _options.Entities.MattressRightHeatingLevel; + domain = "number"; + service = "set_value"; + serviceData["value"] = ReadInt(input.Value, 0, 4, "床垫右侧加热档位"); + break; + case "mattress_left_mode": + targetEntityId = _options.Entities.MattressLeftMode; + domain = "select"; + service = "select_option"; + serviceData["option"] = ReadMattressMode(input.Value); + break; + case "mattress_right_mode": + targetEntityId = _options.Entities.MattressRightMode; + domain = "select"; + service = "select_option"; + serviceData["option"] = ReadMattressMode(input.Value); + break; + case "uv": + targetEntityId = _options.Entities.DehumidifierUv; + domain = "switch"; + service = ReadBool(input.Value) ? "turn_on" : "turn_off"; + break; + default: + return new HomeAssistantSetParameterOutput + { + Success = false, + DeviceId = input.DeviceId, + ParameterType = input.ParameterType, + Message = $"不支持的参数类型: {input.ParameterType}" + }; + } + + if (string.IsNullOrWhiteSpace(targetEntityId)) + throw new InvalidOperationException("该参数尚未配置 Home Assistant 实体"); + if (!string.IsNullOrWhiteSpace(input.EntityId) && + !string.Equals(input.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("请求实体与该参数的受信任实体不匹配"); + if (!targetDevice.Entities.Any(e => string.Equals(e.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException("该参数不属于指定设备"); + if (!stateMap.TryGetValue(targetEntityId, out var targetState) || !IsAvailable(targetState)) + throw new InvalidOperationException("目标参数实体当前离线或不可用"); + + serviceData["entity_id"] = targetEntityId; + await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken); + + try + { + var userId = GetCurrentUserId(); + var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); + if (profile != null) + { + var customConfig = string.IsNullOrWhiteSpace(profile.CustomSceneConfigJson) + ? new PersonalizedSceneConfig() + : JsonSerializer.Deserialize(profile.CustomSceneConfigJson) ?? new PersonalizedSceneConfig(); + + switch (parameterType) + { + case "humidity": customConfig.TargetHumidity = (int)serviceData["humidity"]!; break; + case "fan_level": customConfig.FanLevel = (string)serviceData["option"]!; break; + case "dehumidifier_mode": customConfig.DehumidifierMode = (string)serviceData["mode"]!; break; + case "brightness": customConfig.LightBrightnessPercent = (int)serviceData["brightness_pct"]!; break; + case "color_temp": customConfig.LightColorTemperatureKelvin = (int)serviceData["color_temp_kelvin"]!; break; + case "volume": customConfig.SpeakerVolumePercent = ReadInt(input.Value, 0, 100, "音箱音量"); break; + case "speaker_sleep_mode": customConfig.EnableSpeakerSleepMode = ReadBool(input.Value); break; + case "uv": customConfig.EnableUv = ReadBool(input.Value); break; + case "mattress_left_heating": + case "mattress_right_heating": customConfig.MattressHeatingLevel = ReadInt(input.Value, 0, 4, "床垫加热档位"); break; + case "mattress_left_mode": + case "mattress_right_mode": customConfig.MattressMode = ReadMattressMode(input.Value); break; + } + + profile.CustomSceneConfigJson = JsonSerializer.Serialize(customConfig); + await _profileRepository.UpdateAsync(profile); + } + } + catch + { + } + + return new HomeAssistantSetParameterOutput + { + Success = true, + DeviceId = input.DeviceId, + ParameterType = input.ParameterType ?? "", + Message = "参数调节成功" + }; + } + catch (Exception ex) + { + return new HomeAssistantSetParameterOutput + { + Success = false, + DeviceId = input.DeviceId, + ParameterType = input.ParameterType ?? "", + Message = $"调节失败:{ex.Message}" + }; + } + } + [Authorize] [HttpPost("/api/health/home-assistant/scenes/sleep")] - public Task ExecuteSleepScene(CancellationToken cancellationToken) - => ExecuteScene("sleep", "舒睡模式", ExecuteSleepActions, cancellationToken); + public async Task ExecuteSleepScene(CancellationToken cancellationToken) + { + PersonalizedSceneConfig? customConfig = null; + try + { + var userId = GetCurrentUserId(); + var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId); + if (!string.IsNullOrWhiteSpace(profile?.CustomSceneConfigJson)) + { + customConfig = JsonSerializer.Deserialize(profile.CustomSceneConfigJson); + } + } + catch + { + } + + return await ExecuteScene("sleep", "舒睡模式", (states, results, ct) => ExecuteSleepActions(states, results, customConfig, ct), cancellationToken); + } [Authorize] [HttpPost("/api/health/home-assistant/scenes/all-off")] @@ -264,48 +494,69 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie private async Task ExecuteSleepActions( IReadOnlyDictionary states, List results, + PersonalizedSceneConfig? customConfig, CancellationToken cancellationToken) { var entities = _options.Entities; var scene = _options.SleepScene; + var targetHumidity = customConfig?.TargetHumidity ?? scene.TargetHumidity; + var dehumMode = customConfig?.DehumidifierMode ?? scene.DehumidifierMode; + var fanLevel = customConfig?.FanLevel ?? scene.FanLevel; + var enableUv = customConfig?.EnableUv ?? scene.EnableUv; + var lightBrightness = customConfig?.LightBrightnessPercent ?? scene.LightBrightnessPercent; + var lightColorTemp = customConfig?.LightColorTemperatureKelvin ?? scene.LightColorTemperatureKelvin; + var enableFragrance = customConfig?.EnableFragrance ?? scene.EnableFragrance; + var enableSpeakerSleepMode = customConfig?.EnableSpeakerSleepMode ?? scene.EnableSpeakerSleepMode; + var speakerVolume = customConfig?.SpeakerVolumePercent ?? 15; + var mattressHeatingLevel = customConfig?.MattressHeatingLevel ?? scene.MattressHeatingLevel; + var mattressMode = customConfig?.MattressMode ?? scene.MattressMode; + 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); + new Dictionary { ["humidity"] = targetHumidity }, cancellationToken); await RunAction(states, results, "切换睡眠模式", entities.Dehumidifier, "humidifier", "set_mode", - new Dictionary { ["mode"] = scene.DehumidifierMode }, cancellationToken); + new Dictionary { ["mode"] = dehumMode }, 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); + new Dictionary { ["option"] = fanLevel }, cancellationToken); + await RunAction(states, results, enableUv ? "开启 UV 杀菌" : "关闭 UV 杀菌", entities.DehumidifierUv, + "switch", enableUv ? "turn_on" : "turn_off", null, cancellationToken); - if (scene.EnableFragrance) + if (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); + new Dictionary { ["brightness_pct"] = lightBrightness }, cancellationToken); } await RunAction(states, results, "开启助眠灯光", entities.AmbientLight, "light", "turn_on", new Dictionary { - ["brightness_pct"] = scene.LightBrightnessPercent, - ["color_temp_kelvin"] = scene.LightColorTemperatureKelvin + ["brightness_pct"] = lightBrightness, + ["color_temp_kelvin"] = lightColorTemp }, cancellationToken); - if (scene.EnableSpeakerSleepMode) + if (enableSpeakerSleepMode) + { await RunAction(states, results, "开启音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_on", null, cancellationToken); + await RunAction(states, results, "设置助眠音量", entities.Speaker, "media_player", "volume_set", + new Dictionary { ["volume_level"] = speakerVolume / 100d }, cancellationToken); + } + else + { + await RunAction(states, results, "关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", null, cancellationToken); + } if (scene.EnableMattress) { - await RunAction(states, results, "设置床垫左侧低档加热", entities.MattressLeftHeatingLevel, "number", "set_value", - new Dictionary { ["value"] = scene.MattressHeatingLevel }, cancellationToken); - await RunAction(states, results, "设置床垫右侧低档加热", entities.MattressRightHeatingLevel, "number", "set_value", - new Dictionary { ["value"] = scene.MattressHeatingLevel }, cancellationToken); - await RunAction(states, results, "设置床垫左侧智能适配", entities.MattressLeftMode, "select", "select_option", - new Dictionary { ["option"] = scene.MattressMode }, cancellationToken); - await RunAction(states, results, "设置床垫右侧智能适配", entities.MattressRightMode, "select", "select_option", - new Dictionary { ["option"] = scene.MattressMode }, cancellationToken); + await RunAction(states, results, "设置床垫左侧加热档位", entities.MattressLeftHeatingLevel, "number", "set_value", + new Dictionary { ["value"] = mattressHeatingLevel }, cancellationToken); + await RunAction(states, results, "设置床垫右侧加热档位", entities.MattressRightHeatingLevel, "number", "set_value", + new Dictionary { ["value"] = mattressHeatingLevel }, cancellationToken); + await RunAction(states, results, "设置床垫左侧模式", entities.MattressLeftMode, "select", "select_option", + new Dictionary { ["option"] = mattressMode }, cancellationToken); + await RunAction(states, results, "设置床垫右侧模式", entities.MattressRightMode, "select", "select_option", + new Dictionary { ["option"] = mattressMode }, cancellationToken); } } @@ -733,10 +984,85 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie return safe.Count > 0 ? safe : null; } - private List BuildScenePresets( + private Dictionary BuildControlParameterStates( IReadOnlyDictionary stateMap) { - var scene = _options.SleepScene; + var configured = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["humidity"] = _options.Entities.Dehumidifier, + ["fan_level"] = _options.Entities.DehumidifierFanLevel, + ["uv"] = _options.Entities.DehumidifierUv, + ["brightness"] = _options.Entities.AmbientLight, + ["color_temp"] = _options.Entities.AmbientLight, + ["volume"] = _options.Entities.Speaker, + ["speaker_sleep_mode"] = _options.Entities.SpeakerSleepMode, + ["mattress_left_heating"] = _options.Entities.MattressLeftHeatingLevel, + ["mattress_right_heating"] = _options.Entities.MattressRightHeatingLevel, + ["mattress_left_mode"] = _options.Entities.MattressLeftMode, + ["mattress_right_mode"] = _options.Entities.MattressRightMode + }; + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (parameterType, entityId) in configured) + { + if (string.IsNullOrWhiteSpace(entityId) || !stateMap.TryGetValue(entityId, out var state)) continue; + result[parameterType] = new HomeAssistantParameterStateOutput + { + EntityId = entityId, + State = state.State, + Available = IsAvailable(state), + Attributes = ExtractSafeAttributes(state) + }; + } + return result; + } + + private static string ReadString(object? value) + { + if (value is JsonElement element) + { + return element.ValueKind == JsonValueKind.String + ? element.GetString()?.Trim() ?? string.Empty + : element.ToString().Trim(); + } + return value?.ToString()?.Trim() ?? string.Empty; + } + + private static int ReadInt(object? value, int min, int max, string label) + { + if (!int.TryParse(ReadString(value), out var parsed) || parsed < min || parsed > max) + throw new InvalidOperationException($"{label}应在 {min} 到 {max} 之间"); + return parsed; + } + + private static bool ReadBool(object? value) + { + if (value is JsonElement element && element.ValueKind is JsonValueKind.True or JsonValueKind.False) + return element.GetBoolean(); + if (bool.TryParse(ReadString(value), out var parsed)) return parsed; + throw new InvalidOperationException("开关参数无效"); + } + + private static string ReadMattressMode(object? value) + { + var mode = ReadString(value); + if (mode is not ("无" or "瑜伽助眠" or "智能适配" or "智能腰托")) + throw new InvalidOperationException("床垫模式无效"); + return mode; + } + + private List BuildScenePresets( + IReadOnlyDictionary stateMap, + PersonalizedSceneConfig? userConfig = null) + { + var defaultScene = _options.SleepScene; + var targetHumidity = userConfig?.TargetHumidity ?? defaultScene.TargetHumidity; + var dehumMode = userConfig?.DehumidifierMode ?? defaultScene.DehumidifierMode; + var fanLevel = userConfig?.FanLevel ?? defaultScene.FanLevel; + var enableUv = userConfig?.EnableUv ?? defaultScene.EnableUv; + var lightBrightness = userConfig?.LightBrightnessPercent ?? defaultScene.LightBrightnessPercent; + var lightColorTemp = userConfig?.LightColorTemperatureKelvin ?? defaultScene.LightColorTemperatureKelvin; + var enableFragrance = userConfig?.EnableFragrance ?? defaultScene.EnableFragrance; + var enableSpeaker = userConfig?.EnableSpeakerSleepMode ?? defaultScene.EnableSpeakerSleepMode; var presets = new List(); if (!string.IsNullOrWhiteSpace(_options.Entities.Dehumidifier)) @@ -747,18 +1073,18 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie Category = nameof(HealthDeviceCategory.EnvironmentControl), EntityId = _options.Entities.Dehumidifier, Action = "开启恒湿舒睡", - Detail = $"目标湿度 {scene.TargetHumidity}% · {scene.DehumidifierMode}模式 · 风速{scene.FanLevel} · UV{(scene.EnableUv ? "开" : "关")}", + Detail = $"目标湿度 {targetHumidity}% · {dehumMode}模式 · 风速{fanLevel} · UV{(enableUv ? "开" : "关")}", Parameters = new() { - ["target_humidity"] = $"{scene.TargetHumidity}%", - ["mode"] = scene.DehumidifierMode, - ["fan_level"] = scene.FanLevel, - ["uv"] = scene.EnableUv ? "开启" : "关闭" + ["target_humidity"] = $"{targetHumidity}%", + ["mode"] = dehumMode, + ["fan_level"] = fanLevel, + ["uv"] = enableUv ? "开启" : "关闭" } }); } - if (scene.EnableFragrance && !string.IsNullOrWhiteSpace(_options.Entities.FragranceSwitch)) + if (enableFragrance && !string.IsNullOrWhiteSpace(_options.Entities.FragranceSwitch)) { presets.Add(new HomeAssistantScenePresetItem { @@ -779,43 +1105,44 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie Category = nameof(HealthDeviceCategory.Lighting), EntityId = _options.Entities.AmbientLight, Action = "开启暖光助眠", - Detail = $"亮度 {scene.LightBrightnessPercent}% · 色温 {scene.LightColorTemperatureKelvin}K", + Detail = $"亮度 {lightBrightness}% · 色温 {lightColorTemp}K", Parameters = new() { - ["brightness"] = $"{scene.LightBrightnessPercent}%", - ["color_temp"] = $"{scene.LightColorTemperatureKelvin}K" + ["brightness"] = $"{lightBrightness}%", + ["color_temp"] = $"{lightColorTemp}K" } }); } - if (scene.EnableSpeakerSleepMode && !string.IsNullOrWhiteSpace(_options.Entities.SpeakerSleepMode)) + if (enableSpeaker && !string.IsNullOrWhiteSpace(_options.Entities.SpeakerSleepMode)) { + var vol = userConfig?.SpeakerVolumePercent ?? 15; presets.Add(new HomeAssistantScenePresetItem { DeviceName = "Xiaomi Sound Pro", Category = nameof(HealthDeviceCategory.Media), EntityId = _options.Entities.SpeakerSleepMode, Action = "开启音箱睡眠模式", - Detail = "仅切换已确认的睡眠模式开关,不自动播放未知媒体内容", - Parameters = new() { ["sleep_mode"] = "开启" } + Detail = $"切换睡眠模式开关 · 音量 {vol}%", + Parameters = new() { ["sleep_mode"] = "开启", ["volume"] = $"{vol}%" } }); } - if (scene.EnableMattress && !string.IsNullOrWhiteSpace(_options.Entities.MattressLeftHeatingLevel)) + if (defaultScene.EnableMattress && !string.IsNullOrWhiteSpace(_options.Entities.MattressLeftHeatingLevel)) { + var heatingLevel = userConfig?.MattressHeatingLevel ?? defaultScene.MattressHeatingLevel; + var mattressMode = userConfig?.MattressMode ?? defaultScene.MattressMode; presets.Add(new HomeAssistantScenePresetItem { DeviceName = "8H AI智能好睡眠床垫X", Category = nameof(HealthDeviceCategory.Sleep), EntityId = _options.Entities.MattressLeftHeatingLevel, - Action = "开启低档加热与智能适配", - Detail = $"左右加热 {scene.MattressHeatingLevel} 档 · 左右气袋模式 {scene.MattressMode}", + Action = "开启分区加热与承托模式", + Detail = $"左右加热 {heatingLevel} 档 · {mattressMode}", Parameters = new() { - ["left_heating_level"] = scene.MattressHeatingLevel, - ["right_heating_level"] = scene.MattressHeatingLevel, - ["left_mode"] = scene.MattressMode, - ["right_mode"] = scene.MattressMode + ["heating_level"] = heatingLevel, + ["mode"] = mattressMode } }); }