Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61b3ce3e7 | ||
|
|
c1c86b56b6 | ||
|
|
a063be61cd |
@@ -12,6 +12,10 @@
|
|||||||
"AmbientLight": "light.lemesh_cn_2041198316_wy0d02_s_2_light",
|
"AmbientLight": "light.lemesh_cn_2041198316_wy0d02_s_2_light",
|
||||||
"Speaker": "media_player.xiaomi_cn_1221084723_l17a",
|
"Speaker": "media_player.xiaomi_cn_1221084723_l17a",
|
||||||
"SpeakerSleepMode": "switch.xiaomi_cn_1221084723_l17a_sleep_mode_p_7_3",
|
"SpeakerSleepMode": "switch.xiaomi_cn_1221084723_l17a_sleep_mode_p_7_3",
|
||||||
|
"MattressLeftHeatingLevel": "number.qushui_cn_2108064504_zerox_left_level_p_6_1",
|
||||||
|
"MattressRightHeatingLevel": "number.qushui_cn_2108064504_zerox_right_level_p_6_2",
|
||||||
|
"MattressLeftMode": "select.qushui_cn_2108064504_zerox_left_mode_p_5_1",
|
||||||
|
"MattressRightMode": "select.qushui_cn_2108064504_zerox_right_mode_p_5_2",
|
||||||
"Temperature": "sensor.mirsz_cn_2027039858_et71_temperature_p_3_1",
|
"Temperature": "sensor.mirsz_cn_2027039858_et71_temperature_p_3_1",
|
||||||
"Humidity": "sensor.mirsz_cn_2027039858_et71_relative_humidity_p_3_2",
|
"Humidity": "sensor.mirsz_cn_2027039858_et71_relative_humidity_p_3_2",
|
||||||
"Pm25": "sensor.mirsz_cn_2027039858_et71_pm2_5_density_p_3_3",
|
"Pm25": "sensor.mirsz_cn_2027039858_et71_pm2_5_density_p_3_3",
|
||||||
@@ -26,7 +30,10 @@
|
|||||||
"LightBrightnessPercent": 20,
|
"LightBrightnessPercent": 20,
|
||||||
"LightColorTemperatureKelvin": 2700,
|
"LightColorTemperatureKelvin": 2700,
|
||||||
"EnableFragrance": true,
|
"EnableFragrance": true,
|
||||||
"EnableSpeakerSleepMode": false,
|
"EnableSpeakerSleepMode": true,
|
||||||
|
"EnableMattress": true,
|
||||||
|
"MattressHeatingLevel": 1,
|
||||||
|
"MattressMode": "智能适配",
|
||||||
"EnableUv": false
|
"EnableUv": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ public sealed class HealthProfile : EntityBase
|
|||||||
|
|
||||||
[SugarColumn(ColumnDescription = "健康目标", Length = 500, IsNullable = true)]
|
[SugarColumn(ColumnDescription = "健康目标", Length = 500, IsNullable = true)]
|
||||||
public string? HealthGoal { get; set; }
|
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", "用户健康板块选择")]
|
[SugarTable("health_module_selection", "用户健康板块选择")]
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public sealed class HomeAssistantEntityOptions
|
|||||||
public string AmbientLight { get; set; } = string.Empty;
|
public string AmbientLight { get; set; } = string.Empty;
|
||||||
public string Speaker { get; set; } = string.Empty;
|
public string Speaker { get; set; } = string.Empty;
|
||||||
public string SpeakerSleepMode { get; set; } = string.Empty;
|
public string SpeakerSleepMode { get; set; } = string.Empty;
|
||||||
|
public string MattressLeftHeatingLevel { get; set; } = string.Empty;
|
||||||
|
public string MattressRightHeatingLevel { get; set; } = string.Empty;
|
||||||
|
public string MattressLeftMode { get; set; } = string.Empty;
|
||||||
|
public string MattressRightMode { get; set; } = string.Empty;
|
||||||
public string Temperature { get; set; } = string.Empty;
|
public string Temperature { get; set; } = string.Empty;
|
||||||
public string Humidity { get; set; } = string.Empty;
|
public string Humidity { get; set; } = string.Empty;
|
||||||
public string Pm25 { get; set; } = string.Empty;
|
public string Pm25 { get; set; } = string.Empty;
|
||||||
@@ -39,5 +43,8 @@ public sealed class HomeAssistantSleepSceneOptions
|
|||||||
public int LightColorTemperatureKelvin { get; set; } = 2700;
|
public int LightColorTemperatureKelvin { get; set; } = 2700;
|
||||||
public bool EnableFragrance { get; set; } = true;
|
public bool EnableFragrance { get; set; } = true;
|
||||||
public bool EnableSpeakerSleepMode { get; set; }
|
public bool EnableSpeakerSleepMode { get; set; }
|
||||||
|
public bool EnableMattress { get; set; }
|
||||||
|
public int MattressHeatingLevel { get; set; } = 1;
|
||||||
|
public string MattressMode { get; set; } = "智能适配";
|
||||||
public bool EnableUv { get; set; }
|
public bool EnableUv { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,4 +94,66 @@ public sealed class HealthDashboardOutput
|
|||||||
public SaveHealthProfileInput? Profile { get; set; }
|
public SaveHealthProfileInput? Profile { get; set; }
|
||||||
public List<string> SelectedModules { get; set; } = [];
|
public List<string> SelectedModules { get; set; } = [];
|
||||||
public HealthPlanOutput? CurrentPlan { 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; } = "正常";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public sealed class HomeAssistantDiscoveredDeviceOutput
|
|||||||
public string? AreaName { get; set; }
|
public string? AreaName { get; set; }
|
||||||
public string Category { get; set; } = nameof(HealthDeviceCategory.Other);
|
public string Category { get; set; } = nameof(HealthDeviceCategory.Other);
|
||||||
public bool Online { get; set; }
|
public bool Online { get; set; }
|
||||||
|
public string? PrimaryControlEntityId { get; set; }
|
||||||
public List<HomeAssistantEntityOutput> Entities { get; set; } = [];
|
public List<HomeAssistantEntityOutput> Entities { get; set; } = [];
|
||||||
public Dictionary<string, object?> Metrics { get; set; } = [];
|
public Dictionary<string, object?> Metrics { get; set; } = [];
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,61 @@ public sealed class HomeAssistantStatusOutput
|
|||||||
public List<HomeAssistantDiscoveredDeviceOutput> Devices { get; set; } = [];
|
public List<HomeAssistantDiscoveredDeviceOutput> Devices { get; set; } = [];
|
||||||
public HomeAssistantSceneDevicesSummaryOutput SceneDevices { get; set; } = new();
|
public HomeAssistantSceneDevicesSummaryOutput SceneDevices { get; set; } = new();
|
||||||
public HomeAssistantEnvironmentOutput Environment { get; set; } = new();
|
public HomeAssistantEnvironmentOutput Environment { get; set; } = new();
|
||||||
|
public List<HomeAssistantScenePresetItem> ScenePresets { get; set; } = [];
|
||||||
|
public Dictionary<string, HomeAssistantParameterStateOutput> 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<string, object?>? Attributes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HomeAssistantScenePresetItem
|
||||||
|
{
|
||||||
|
public string DeviceName { get; set; } = string.Empty;
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
public string EntityId { get; set; } = string.Empty;
|
||||||
|
public bool Available { get; set; }
|
||||||
|
public string AvailabilityMessage { get; set; } = string.Empty;
|
||||||
|
public string Action { get; set; } = string.Empty;
|
||||||
|
public string Detail { get; set; } = string.Empty;
|
||||||
|
public Dictionary<string, object?> Parameters { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HomeAssistantDeviceSwitchInput
|
||||||
|
{
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
public string? EntityId { get; set; }
|
||||||
|
public bool TurnOn { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HomeAssistantDeviceSwitchOutput
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
public string EntityId { get; set; } = string.Empty;
|
||||||
|
public string State { get; set; } = string.Empty;
|
||||||
|
public bool? IsMuted { get; set; }
|
||||||
|
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 sealed class HomeAssistantSceneOutput
|
||||||
|
|||||||
@@ -104,15 +104,151 @@ public sealed class HealthAppService : IDynamicApiController, ITransient
|
|||||||
.OrderByDescending(item => item.Version)
|
.OrderByDescending(item => item.Version)
|
||||||
.FirstAsync();
|
.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<PersonalizedSceneConfig>(profile.CustomSceneConfigJson);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sceneConfig == null && hasCompletedAssessment)
|
||||||
|
{
|
||||||
|
var answers = JsonSerializer.Deserialize<HealthAssessmentAnswers>(profile!.AssessmentJson!) ?? new HealthAssessmentAnswers();
|
||||||
|
sceneConfig = CalculatePersonalizedSceneConfig(answers, MapProfile(profile));
|
||||||
|
}
|
||||||
|
|
||||||
return new HealthDashboardOutput
|
return new HealthDashboardOutput
|
||||||
{
|
{
|
||||||
User = MapUser(user),
|
User = MapUser(user),
|
||||||
Profile = profile is null ? null : MapProfile(profile),
|
Profile = profile is null ? null : MapProfile(profile),
|
||||||
SelectedModules = modules,
|
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<HealthAssessmentOutput> 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<HealthAssessmentAnswers>(profile.AssessmentJson) ?? new HealthAssessmentAnswers();
|
||||||
|
|
||||||
|
var hasCompleted = !string.IsNullOrWhiteSpace(profile.AssessmentJson);
|
||||||
|
var sceneConfig = string.IsNullOrWhiteSpace(profile.CustomSceneConfigJson)
|
||||||
|
? CalculatePersonalizedSceneConfig(answers, MapProfile(profile))
|
||||||
|
: JsonSerializer.Deserialize<PersonalizedSceneConfig>(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<HealthAssessmentOutput> 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<string> { "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]
|
[Authorize]
|
||||||
[HttpPut("/api/health/profile")]
|
[HttpPut("/api/health/profile")]
|
||||||
public async Task<SaveHealthProfileInput> SaveProfile(SaveHealthProfileInput input)
|
public async Task<SaveHealthProfileInput> SaveProfile(SaveHealthProfileInput input)
|
||||||
@@ -328,4 +464,200 @@ public sealed class HealthAppService : IDynamicApiController, ITransient
|
|||||||
User = MapUser(user)
|
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}全套智能家具启动参数已完成个性化配置。";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
private readonly HomeAssistantRegistryService _registryService;
|
private readonly HomeAssistantRegistryService _registryService;
|
||||||
private readonly HomeAssistantOptions _options;
|
private readonly HomeAssistantOptions _options;
|
||||||
private readonly SqlSugarRepository<HealthSceneExecution> _executionRepository;
|
private readonly SqlSugarRepository<HealthSceneExecution> _executionRepository;
|
||||||
|
private readonly SqlSugarRepository<HealthProfile> _profileRepository;
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
|
||||||
public HealthHomeAssistantService(
|
public HealthHomeAssistantService(
|
||||||
@@ -25,12 +26,14 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
HomeAssistantRegistryService registryService,
|
HomeAssistantRegistryService registryService,
|
||||||
IOptions<HomeAssistantOptions> options,
|
IOptions<HomeAssistantOptions> options,
|
||||||
SqlSugarRepository<HealthSceneExecution> executionRepository,
|
SqlSugarRepository<HealthSceneExecution> executionRepository,
|
||||||
|
SqlSugarRepository<HealthProfile> profileRepository,
|
||||||
IHttpContextAccessor httpContextAccessor)
|
IHttpContextAccessor httpContextAccessor)
|
||||||
{
|
{
|
||||||
_homeAssistant = homeAssistant;
|
_homeAssistant = homeAssistant;
|
||||||
_registryService = registryService;
|
_registryService = registryService;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_executionRepository = executionRepository;
|
_executionRepository = executionRepository;
|
||||||
|
_profileRepository = profileRepository;
|
||||||
_httpContextAccessor = httpContextAccessor;
|
_httpContextAccessor = httpContextAccessor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +48,26 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
var registries = await _registryService.GetRegistriesAsync(cancellationToken);
|
var registries = await _registryService.GetRegistriesAsync(cancellationToken);
|
||||||
|
|
||||||
var discoveredDevices = BuildDiscoveredDevices(registries, stateMap);
|
var discoveredDevices = BuildDiscoveredDevices(registries, stateMap);
|
||||||
var summary = BuildSummary(discoveredDevices, states);
|
var summary = BuildSummary(discoveredDevices);
|
||||||
var sceneDevices = BuildSceneDevicesSummary(stateMap);
|
var sceneDevices = BuildSceneDevicesSummary(stateMap);
|
||||||
var environment = BuildEnvironmentOutput(stateMap, discoveredDevices);
|
var environment = BuildEnvironmentOutput(stateMap, discoveredDevices);
|
||||||
|
|
||||||
|
PersonalizedSceneConfig? userConfig = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userId = GetCurrentUserId();
|
||||||
|
var profile = await _profileRepository.GetFirstAsync(p => p.UserId == userId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(profile?.CustomSceneConfigJson))
|
||||||
|
{
|
||||||
|
userConfig = JsonSerializer.Deserialize<PersonalizedSceneConfig>(profile.CustomSceneConfigJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
var scenePresets = BuildScenePresets(stateMap, userConfig);
|
||||||
|
|
||||||
return new HomeAssistantStatusOutput
|
return new HomeAssistantStatusOutput
|
||||||
{
|
{
|
||||||
Connected = true,
|
Connected = true,
|
||||||
@@ -57,7 +76,9 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
Summary = summary,
|
Summary = summary,
|
||||||
Devices = discoveredDevices,
|
Devices = discoveredDevices,
|
||||||
SceneDevices = sceneDevices,
|
SceneDevices = sceneDevices,
|
||||||
Environment = environment
|
Environment = environment,
|
||||||
|
ScenePresets = scenePresets,
|
||||||
|
ControlParameters = BuildControlParameterStates(stateMap)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
@@ -69,16 +90,405 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
CheckedAt = DateTimeOffset.Now,
|
CheckedAt = DateTimeOffset.Now,
|
||||||
Summary = new HomeAssistantSummaryOutput(),
|
Summary = new HomeAssistantSummaryOutput(),
|
||||||
Devices = [],
|
Devices = [],
|
||||||
SceneDevices = new HomeAssistantSceneDevicesSummaryOutput { Total = 4, Available = 0 },
|
SceneDevices = BuildSceneDevicesSummary(new Dictionary<string, HomeAssistantState>(StringComparer.OrdinalIgnoreCase)),
|
||||||
Environment = new HomeAssistantEnvironmentOutput()
|
Environment = new HomeAssistantEnvironmentOutput(),
|
||||||
|
ScenePresets = BuildScenePresets(new Dictionary<string, HomeAssistantState>(StringComparer.OrdinalIgnoreCase)),
|
||||||
|
ControlParameters = []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("/api/health/home-assistant/devices/switch")]
|
||||||
|
public async Task<HomeAssistantDeviceSwitchOutput> SwitchDevice(
|
||||||
|
[FromBody] HomeAssistantDeviceSwitchInput input,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input.DeviceId))
|
||||||
|
{
|
||||||
|
return new HomeAssistantDeviceSwitchOutput
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Message = "未指定控制设备"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var states = await _homeAssistant.GetStatesAsync(cancellationToken);
|
||||||
|
var stateMap = states.ToDictionary(s => s.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 || string.IsNullOrWhiteSpace(targetDevice.PrimaryControlEntityId))
|
||||||
|
{
|
||||||
|
return new HomeAssistantDeviceSwitchOutput
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
DeviceId = input.DeviceId,
|
||||||
|
Message = "该设备没有经过确认的主控开关"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetEntityId = targetDevice.PrimaryControlEntityId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(input.EntityId) &&
|
||||||
|
!string.Equals(input.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new HomeAssistantDeviceSwitchOutput
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
DeviceId = input.DeviceId,
|
||||||
|
EntityId = input.EntityId,
|
||||||
|
Message = "请求的实体不属于该设备的主控能力"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = targetDevice.Entities.FirstOrDefault(e =>
|
||||||
|
string.Equals(e.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (candidate is null || candidate.Domain is not ("switch" or "humidifier" or "light" or "media_player" or "climate"))
|
||||||
|
{
|
||||||
|
return new HomeAssistantDeviceSwitchOutput
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
DeviceId = input.DeviceId,
|
||||||
|
EntityId = targetEntityId,
|
||||||
|
Message = "该实体不支持安全的开关控制"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var domain = candidate.Domain;
|
||||||
|
|
||||||
|
if (!stateMap.TryGetValue(targetEntityId, out var currentState) || !IsAvailable(currentState))
|
||||||
|
{
|
||||||
|
return new HomeAssistantDeviceSwitchOutput
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
DeviceId = input.DeviceId,
|
||||||
|
EntityId = targetEntityId,
|
||||||
|
Message = "目标设备当前处于离线状态"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetOn = input.TurnOn;
|
||||||
|
bool? targetMuted = null;
|
||||||
|
var service = targetOn ? "turn_on" : "turn_off";
|
||||||
|
if (domain == "media_player")
|
||||||
|
{
|
||||||
|
// Xiaomi Sound Pro 的 media_player state 会长期保持 playing,不能用它
|
||||||
|
// 判断声音是否开启;is_volume_muted 才是该实体可稳定回读的控制状态。
|
||||||
|
var currentMutedText = currentState.GetStringAttribute("is_volume_muted");
|
||||||
|
var currentMuted = bool.TryParse(currentMutedText, out var parsedMuted) && parsedMuted;
|
||||||
|
targetMuted = !currentMuted;
|
||||||
|
targetOn = !targetMuted.Value;
|
||||||
|
service = "volume_mute";
|
||||||
|
}
|
||||||
|
|
||||||
|
var serviceData = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["entity_id"] = targetEntityId
|
||||||
|
};
|
||||||
|
if (targetMuted.HasValue)
|
||||||
|
{
|
||||||
|
serviceData["is_volume_muted"] = targetMuted.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken);
|
||||||
|
|
||||||
|
var resultState = domain == "media_player"
|
||||||
|
? currentState.State
|
||||||
|
: (targetOn ? "on" : "off");
|
||||||
|
bool? resultMuted = null;
|
||||||
|
|
||||||
|
if (domain == "media_player")
|
||||||
|
{
|
||||||
|
var stateConfirmed = false;
|
||||||
|
for (var attempt = 0; attempt < 5; attempt++)
|
||||||
|
{
|
||||||
|
await Task.Delay(300, cancellationToken);
|
||||||
|
var refreshedStates = await _homeAssistant.GetStatesAsync(cancellationToken);
|
||||||
|
var refreshedState = refreshedStates.FirstOrDefault(s =>
|
||||||
|
string.Equals(s.EntityId, targetEntityId, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (refreshedState is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultState = refreshedState.State;
|
||||||
|
var refreshedMutedText = refreshedState.GetStringAttribute("is_volume_muted");
|
||||||
|
resultMuted = bool.TryParse(refreshedMutedText, out var refreshedMuted)
|
||||||
|
? refreshedMuted
|
||||||
|
: null;
|
||||||
|
stateConfirmed = resultMuted == targetMuted;
|
||||||
|
if (stateConfirmed)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stateConfirmed)
|
||||||
|
{
|
||||||
|
return new HomeAssistantDeviceSwitchOutput
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
DeviceId = input.DeviceId,
|
||||||
|
EntityId = targetEntityId,
|
||||||
|
State = resultState,
|
||||||
|
IsMuted = resultMuted,
|
||||||
|
Message = $"音箱指令已发送,但静音状态未确认更新(当前:{(resultMuted.HasValue ? (resultMuted.Value ? "已静音" : "声音开启") : "未知")})"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HomeAssistantDeviceSwitchOutput
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
DeviceId = input.DeviceId,
|
||||||
|
EntityId = targetEntityId,
|
||||||
|
State = resultState,
|
||||||
|
IsMuted = resultMuted,
|
||||||
|
Message = domain == "media_player"
|
||||||
|
? (targetMuted == true ? "音箱已静音" : "音箱声音已开启")
|
||||||
|
: $"已成功{(targetOn ? "开启" : "关闭")}设备"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HomeAssistantDeviceSwitchOutput
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
DeviceId = input.DeviceId,
|
||||||
|
EntityId = input.EntityId ?? string.Empty,
|
||||||
|
Message = $"控制失败:{ex.Message}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("/api/health/home-assistant/devices/set-parameter")]
|
||||||
|
public async Task<HomeAssistantSetParameterOutput> 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<string, object?>();
|
||||||
|
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<PersonalizedSceneConfig>(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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
var successMessage = parameterType switch
|
||||||
|
{
|
||||||
|
"mattress_left_mode" when !string.Equals(serviceData["option"]?.ToString(), "无", StringComparison.Ordinal) =>
|
||||||
|
"左侧承托模式已启动;床垫共用气泵,右侧会自动停止",
|
||||||
|
"mattress_right_mode" when !string.Equals(serviceData["option"]?.ToString(), "无", StringComparison.Ordinal) =>
|
||||||
|
"右侧承托模式已启动;床垫共用气泵,左侧会自动停止",
|
||||||
|
_ => "参数调节成功"
|
||||||
|
};
|
||||||
|
|
||||||
|
return new HomeAssistantSetParameterOutput
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
DeviceId = input.DeviceId,
|
||||||
|
ParameterType = input.ParameterType ?? "",
|
||||||
|
Message = successMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new HomeAssistantSetParameterOutput
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
DeviceId = input.DeviceId,
|
||||||
|
ParameterType = input.ParameterType ?? "",
|
||||||
|
Message = $"调节失败:{ex.Message}"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[HttpPost("/api/health/home-assistant/scenes/sleep")]
|
[HttpPost("/api/health/home-assistant/scenes/sleep")]
|
||||||
public Task<HomeAssistantSceneOutput> ExecuteSleepScene(CancellationToken cancellationToken)
|
public async Task<HomeAssistantSceneOutput> ExecuteSleepScene(CancellationToken cancellationToken)
|
||||||
=> ExecuteScene("sleep", "舒睡模式", ExecuteSleepActions, 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<PersonalizedSceneConfig>(profile.CustomSceneConfigJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ExecuteScene("sleep", "舒睡模式", (states, results, ct) => ExecuteSleepActions(states, results, customConfig, ct), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[HttpPost("/api/health/home-assistant/scenes/all-off")]
|
[HttpPost("/api/health/home-assistant/scenes/all-off")]
|
||||||
@@ -149,38 +559,75 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
private async Task ExecuteSleepActions(
|
private async Task ExecuteSleepActions(
|
||||||
IReadOnlyDictionary<string, HomeAssistantState> states,
|
IReadOnlyDictionary<string, HomeAssistantState> states,
|
||||||
List<HomeAssistantActionResult> results,
|
List<HomeAssistantActionResult> results,
|
||||||
|
PersonalizedSceneConfig? customConfig,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entities = _options.Entities;
|
var entities = _options.Entities;
|
||||||
var scene = _options.SleepScene;
|
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", "turn_on", null, cancellationToken);
|
||||||
await RunAction(states, results, "设置目标湿度", entities.Dehumidifier, "humidifier", "set_humidity",
|
await RunAction(states, results, "设置目标湿度", entities.Dehumidifier, "humidifier", "set_humidity",
|
||||||
new Dictionary<string, object?> { ["humidity"] = scene.TargetHumidity }, cancellationToken);
|
new Dictionary<string, object?> { ["humidity"] = targetHumidity }, cancellationToken);
|
||||||
await RunAction(states, results, "切换睡眠模式", entities.Dehumidifier, "humidifier", "set_mode",
|
await RunAction(states, results, "切换睡眠模式", entities.Dehumidifier, "humidifier", "set_mode",
|
||||||
new Dictionary<string, object?> { ["mode"] = scene.DehumidifierMode }, cancellationToken);
|
new Dictionary<string, object?> { ["mode"] = dehumMode }, cancellationToken);
|
||||||
await RunAction(states, results, "设置低档风速", entities.DehumidifierFanLevel, "select", "select_option",
|
await RunAction(states, results, "设置低档风速", entities.DehumidifierFanLevel, "select", "select_option",
|
||||||
new Dictionary<string, object?> { ["option"] = scene.FanLevel }, cancellationToken);
|
new Dictionary<string, object?> { ["option"] = fanLevel }, cancellationToken);
|
||||||
await RunAction(states, results, scene.EnableUv ? "开启 UV 杀菌" : "关闭 UV 杀菌", entities.DehumidifierUv,
|
await RunAction(states, results, enableUv ? "开启 UV 杀菌" : "关闭 UV 杀菌", entities.DehumidifierUv,
|
||||||
"switch", scene.EnableUv ? "turn_on" : "turn_off", null, cancellationToken);
|
"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.FragranceSwitch, "switch", "turn_on", null, cancellationToken);
|
||||||
await RunAction(states, results, "开启香氛灯", entities.FragranceLight, "light", "turn_on",
|
await RunAction(states, results, "开启香氛灯", entities.FragranceLight, "light", "turn_on",
|
||||||
new Dictionary<string, object?> { ["brightness_pct"] = scene.LightBrightnessPercent }, cancellationToken);
|
new Dictionary<string, object?> { ["brightness_pct"] = lightBrightness }, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
await RunAction(states, results, "开启助眠灯光", entities.AmbientLight, "light", "turn_on",
|
await RunAction(states, results, "开启助眠灯光", entities.AmbientLight, "light", "turn_on",
|
||||||
new Dictionary<string, object?>
|
new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["brightness_pct"] = scene.LightBrightnessPercent,
|
["brightness_pct"] = lightBrightness,
|
||||||
["color_temp_kelvin"] = scene.LightColorTemperatureKelvin
|
["color_temp_kelvin"] = lightColorTemp
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
if (scene.EnableSpeakerSleepMode)
|
if (enableSpeakerSleepMode)
|
||||||
|
{
|
||||||
await RunAction(states, results, "开启音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_on", null, cancellationToken);
|
await RunAction(states, results, "开启音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_on", null, cancellationToken);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await RunAction(states, results, "关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", null, cancellationToken);
|
||||||
|
}
|
||||||
|
// 音箱声音与睡眠模式是两种不同能力。即使用户未启用睡眠模式,
|
||||||
|
// 一键舒睡也应取消静音并应用个性化音量。
|
||||||
|
await RunAction(states, results, "开启音箱声音", entities.Speaker, "media_player", "volume_mute",
|
||||||
|
new Dictionary<string, object?> { ["is_volume_muted"] = false }, cancellationToken);
|
||||||
|
await RunAction(states, results, "设置助眠音量", entities.Speaker, "media_player", "volume_set",
|
||||||
|
new Dictionary<string, object?> { ["volume_level"] = speakerVolume / 100d }, cancellationToken);
|
||||||
|
|
||||||
|
if (scene.EnableMattress)
|
||||||
|
{
|
||||||
|
await RunAction(states, results, "设置床垫左侧加热档位", entities.MattressLeftHeatingLevel, "number", "set_value",
|
||||||
|
new Dictionary<string, object?> { ["value"] = mattressHeatingLevel }, cancellationToken);
|
||||||
|
await RunAction(states, results, "设置床垫右侧加热档位", entities.MattressRightHeatingLevel, "number", "set_value",
|
||||||
|
new Dictionary<string, object?> { ["value"] = mattressHeatingLevel }, cancellationToken);
|
||||||
|
await RunAction(states, results, "设置床垫左侧模式", entities.MattressLeftMode, "select", "select_option",
|
||||||
|
new Dictionary<string, object?> { ["option"] = mattressMode }, cancellationToken);
|
||||||
|
await RunAction(states, results, "设置床垫右侧模式", entities.MattressRightMode, "select", "select_option",
|
||||||
|
new Dictionary<string, object?> { ["option"] = mattressMode }, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ExecuteAllOffActions(
|
private async Task ExecuteAllOffActions(
|
||||||
IReadOnlyDictionary<string, HomeAssistantState> states,
|
IReadOnlyDictionary<string, HomeAssistantState> states,
|
||||||
@@ -193,6 +640,15 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
await RunAction(states, results, "关闭香氛灯", entities.FragranceLight, "light", "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.AmbientLight, "light", "turn_off", null, cancellationToken);
|
||||||
await RunAction(states, results, "关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", null, cancellationToken);
|
await RunAction(states, results, "关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", null, cancellationToken);
|
||||||
|
await RunAction(states, results, "静音音箱", entities.Speaker, "media_player", "volume_mute",
|
||||||
|
new Dictionary<string, object?> { ["is_volume_muted"] = true }, cancellationToken);
|
||||||
|
if (_options.SleepScene.EnableMattress)
|
||||||
|
{
|
||||||
|
await RunAction(states, results, "关闭床垫左侧加热", entities.MattressLeftHeatingLevel, "number", "set_value",
|
||||||
|
new Dictionary<string, object?> { ["value"] = 0 }, cancellationToken);
|
||||||
|
await RunAction(states, results, "关闭床垫右侧加热", entities.MattressRightHeatingLevel, "number", "set_value",
|
||||||
|
new Dictionary<string, object?> { ["value"] = 0 }, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunAction(
|
private async Task RunAction(
|
||||||
@@ -263,7 +719,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
|| (device.Model?.Contains("xiaomi", StringComparison.OrdinalIgnoreCase) == true)
|
|| (device.Model?.Contains("xiaomi", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|| regEntities.Any(e => string.Equals(e.Platform, "xiaomi_home", StringComparison.OrdinalIgnoreCase));
|
|| regEntities.Any(e => string.Equals(e.Platform, "xiaomi_home", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
if (!isXiaomi && regEntities.Count == 0)
|
if (!isXiaomi)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -302,10 +758,11 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
|
|
||||||
var areaName = !string.IsNullOrWhiteSpace(device.AreaId) && areaMap.TryGetValue(device.AreaId, out var an)
|
var areaName = !string.IsNullOrWhiteSpace(device.AreaId) && areaMap.TryGetValue(device.AreaId, out var an)
|
||||||
? an
|
? an
|
||||||
: "睡眠空间";
|
: null;
|
||||||
|
|
||||||
var category = CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
|
var category = CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
|
||||||
var metrics = ExtractDeviceMetrics(category, entityOutputs);
|
var metrics = ExtractDeviceMetrics(category, entityOutputs);
|
||||||
|
var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs);
|
||||||
|
|
||||||
discovered.Add(new HomeAssistantDiscoveredDeviceOutput
|
discovered.Add(new HomeAssistantDiscoveredDeviceOutput
|
||||||
{
|
{
|
||||||
@@ -316,6 +773,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
AreaName = areaName,
|
AreaName = areaName,
|
||||||
Category = category,
|
Category = category,
|
||||||
Online = isOnline,
|
Online = isOnline,
|
||||||
|
PrimaryControlEntityId = primaryControlEntityId,
|
||||||
Entities = entityOutputs,
|
Entities = entityOutputs,
|
||||||
Metrics = metrics
|
Metrics = metrics
|
||||||
});
|
});
|
||||||
@@ -357,6 +815,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
|
|
||||||
var online = entityOutputs.Any(e => e.Available);
|
var online = entityOutputs.Any(e => e.Available);
|
||||||
var metrics = ExtractDeviceMetrics(category, entityOutputs);
|
var metrics = ExtractDeviceMetrics(category, entityOutputs);
|
||||||
|
var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs);
|
||||||
|
|
||||||
list.Add(new HomeAssistantDiscoveredDeviceOutput
|
list.Add(new HomeAssistantDiscoveredDeviceOutput
|
||||||
{
|
{
|
||||||
@@ -367,6 +826,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
AreaName = area,
|
AreaName = area,
|
||||||
Category = category,
|
Category = category,
|
||||||
Online = online,
|
Online = online,
|
||||||
|
PrimaryControlEntityId = primaryControlEntityId,
|
||||||
Entities = entityOutputs,
|
Entities = entityOutputs,
|
||||||
Metrics = metrics
|
Metrics = metrics
|
||||||
});
|
});
|
||||||
@@ -388,15 +848,42 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string? FindPrimaryControlEntityId(
|
||||||
|
string category,
|
||||||
|
IReadOnlyList<HomeAssistantEntityOutput> entities)
|
||||||
|
{
|
||||||
|
var configuredEntityIds = category switch
|
||||||
|
{
|
||||||
|
nameof(HealthDeviceCategory.EnvironmentControl) => new[] { _options.Entities.Dehumidifier },
|
||||||
|
nameof(HealthDeviceCategory.Fragrance) => new[] { _options.Entities.FragranceSwitch },
|
||||||
|
nameof(HealthDeviceCategory.Lighting) => new[] { _options.Entities.AmbientLight, _options.Entities.FragranceLight },
|
||||||
|
nameof(HealthDeviceCategory.Media) => new[] { _options.Entities.Speaker },
|
||||||
|
_ => Array.Empty<string>()
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var configuredEntityId in configuredEntityIds.Where(id => !string.IsNullOrWhiteSpace(id)))
|
||||||
|
{
|
||||||
|
if (entities.Any(e => string.Equals(e.EntityId, configuredEntityId, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
return configuredEntityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return category switch
|
||||||
|
{
|
||||||
|
nameof(HealthDeviceCategory.EnvironmentControl) => entities.FirstOrDefault(e => e.Domain == "humidifier")?.EntityId,
|
||||||
|
nameof(HealthDeviceCategory.Lighting) => entities.FirstOrDefault(e => e.Domain == "light")?.EntityId,
|
||||||
|
nameof(HealthDeviceCategory.Media) => entities.FirstOrDefault(e => e.Domain == "media_player")?.EntityId,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static HomeAssistantSummaryOutput BuildSummary(
|
private static HomeAssistantSummaryOutput BuildSummary(
|
||||||
IReadOnlyList<HomeAssistantDiscoveredDeviceOutput> devices,
|
IReadOnlyList<HomeAssistantDiscoveredDeviceOutput> devices)
|
||||||
IReadOnlyList<HomeAssistantState> states)
|
|
||||||
{
|
{
|
||||||
var totalDevices = devices.Count;
|
var totalDevices = devices.Count;
|
||||||
var onlineDevices = devices.Count(d => d.Online);
|
var onlineDevices = devices.Count(d => d.Online);
|
||||||
var offlineDevices = totalDevices - onlineDevices;
|
var offlineDevices = totalDevices - onlineDevices;
|
||||||
var totalEntities = states.Count;
|
var totalEntities = devices.Sum(d => d.Entities.Count);
|
||||||
var availableEntities = states.Count(IsAvailable);
|
var availableEntities = devices.Sum(d => d.Entities.Count(e => e.Available));
|
||||||
|
|
||||||
return new HomeAssistantSummaryOutput
|
return new HomeAssistantSummaryOutput
|
||||||
{
|
{
|
||||||
@@ -411,30 +898,29 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
private HomeAssistantSceneDevicesSummaryOutput BuildSceneDevicesSummary(IReadOnlyDictionary<string, HomeAssistantState> stateMap)
|
private HomeAssistantSceneDevicesSummaryOutput BuildSceneDevicesSummary(IReadOnlyDictionary<string, HomeAssistantState> stateMap)
|
||||||
{
|
{
|
||||||
var entities = _options.Entities;
|
var entities = _options.Entities;
|
||||||
var sceneAvailableCount = 0;
|
var sceneEntityIds = new List<string>();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(entities.Dehumidifier) &&
|
if (!string.IsNullOrWhiteSpace(entities.Dehumidifier))
|
||||||
stateMap.TryGetValue(entities.Dehumidifier, out var sDehum) && IsAvailable(sDehum))
|
sceneEntityIds.Add(entities.Dehumidifier);
|
||||||
sceneAvailableCount++;
|
if (_options.SleepScene.EnableFragrance && !string.IsNullOrWhiteSpace(entities.FragranceSwitch))
|
||||||
|
sceneEntityIds.Add(entities.FragranceSwitch);
|
||||||
|
if (!string.IsNullOrWhiteSpace(entities.AmbientLight))
|
||||||
|
sceneEntityIds.Add(entities.AmbientLight);
|
||||||
|
if (!string.IsNullOrWhiteSpace(entities.Speaker))
|
||||||
|
sceneEntityIds.Add(entities.Speaker);
|
||||||
|
else if (_options.SleepScene.EnableSpeakerSleepMode && !string.IsNullOrWhiteSpace(entities.SpeakerSleepMode))
|
||||||
|
sceneEntityIds.Add(entities.SpeakerSleepMode);
|
||||||
|
if (_options.SleepScene.EnableMattress && !string.IsNullOrWhiteSpace(entities.MattressLeftHeatingLevel))
|
||||||
|
sceneEntityIds.Add(entities.MattressLeftHeatingLevel);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(entities.FragranceSwitch) &&
|
sceneEntityIds = sceneEntityIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
stateMap.TryGetValue(entities.FragranceSwitch, out var sFrag) && IsAvailable(sFrag))
|
var sceneAvailableCount = sceneEntityIds.Count(entityId =>
|
||||||
sceneAvailableCount++;
|
stateMap.TryGetValue(entityId, out var state) && IsAvailable(state));
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(entities.AmbientLight) &&
|
|
||||||
stateMap.TryGetValue(entities.AmbientLight, out var sLight) && IsAvailable(sLight))
|
|
||||||
sceneAvailableCount++;
|
|
||||||
|
|
||||||
var speakerOnline = (!string.IsNullOrWhiteSpace(entities.Speaker) &&
|
|
||||||
stateMap.TryGetValue(entities.Speaker, out var sSpk) && IsAvailable(sSpk))
|
|
||||||
|| (!string.IsNullOrWhiteSpace(entities.SpeakerSleepMode) &&
|
|
||||||
stateMap.TryGetValue(entities.SpeakerSleepMode, out var sSpkSleep) && IsAvailable(sSpkSleep));
|
|
||||||
if (speakerOnline) sceneAvailableCount++;
|
|
||||||
|
|
||||||
return new HomeAssistantSceneDevicesSummaryOutput
|
return new HomeAssistantSceneDevicesSummaryOutput
|
||||||
{
|
{
|
||||||
Total = 4,
|
Total = sceneEntityIds.Count,
|
||||||
Available = Math.Min(4, sceneAvailableCount)
|
Available = sceneAvailableCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +956,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
Temperature = GetVal(entities.Temperature, "temperature"),
|
Temperature = GetVal(entities.Temperature, "temperature"),
|
||||||
Humidity = humidity,
|
Humidity = humidity,
|
||||||
Pm25 = GetVal(entities.Pm25, "pm25"),
|
Pm25 = GetVal(entities.Pm25, "pm25"),
|
||||||
|
Pm10 = GetVal(string.Empty, "pm10"),
|
||||||
Co2 = GetVal(entities.Co2, "co2"),
|
Co2 = GetVal(entities.Co2, "co2"),
|
||||||
Hcho = GetVal(entities.Formaldehyde, "hcho"),
|
Hcho = GetVal(entities.Formaldehyde, "hcho"),
|
||||||
Tvoc = GetVal(entities.Tvoc, "tvoc")
|
Tvoc = GetVal(entities.Tvoc, "tvoc")
|
||||||
@@ -570,6 +1057,180 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|
|||||||
return safe.Count > 0 ? safe : null;
|
return safe.Count > 0 ? safe : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, HomeAssistantParameterStateOutput> BuildControlParameterStates(
|
||||||
|
IReadOnlyDictionary<string, HomeAssistantState> stateMap)
|
||||||
|
{
|
||||||
|
var configured = new Dictionary<string, string>(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<string, HomeAssistantParameterStateOutput>(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<HomeAssistantScenePresetItem> BuildScenePresets(
|
||||||
|
IReadOnlyDictionary<string, HomeAssistantState> 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<HomeAssistantScenePresetItem>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.Entities.Dehumidifier))
|
||||||
|
{
|
||||||
|
presets.Add(new HomeAssistantScenePresetItem
|
||||||
|
{
|
||||||
|
DeviceName = "米家变频净化除湿机 Max",
|
||||||
|
Category = nameof(HealthDeviceCategory.EnvironmentControl),
|
||||||
|
EntityId = _options.Entities.Dehumidifier,
|
||||||
|
Action = "开启恒湿舒睡",
|
||||||
|
Detail = $"目标湿度 {targetHumidity}% · {dehumMode}模式 · 风速{fanLevel} · UV{(enableUv ? "开" : "关")}",
|
||||||
|
Parameters = new()
|
||||||
|
{
|
||||||
|
["target_humidity"] = $"{targetHumidity}%",
|
||||||
|
["mode"] = dehumMode,
|
||||||
|
["fan_level"] = fanLevel,
|
||||||
|
["uv"] = enableUv ? "开启" : "关闭"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableFragrance && !string.IsNullOrWhiteSpace(_options.Entities.FragranceSwitch))
|
||||||
|
{
|
||||||
|
presets.Add(new HomeAssistantScenePresetItem
|
||||||
|
{
|
||||||
|
DeviceName = "米家智能香氛机",
|
||||||
|
Category = nameof(HealthDeviceCategory.Fragrance),
|
||||||
|
EntityId = _options.Entities.FragranceSwitch,
|
||||||
|
Action = "开启香氛释放",
|
||||||
|
Detail = "开启香氛机;香氛灯可用时同步开启柔光",
|
||||||
|
Parameters = new() { ["power"] = "开启" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.Entities.AmbientLight))
|
||||||
|
{
|
||||||
|
presets.Add(new HomeAssistantScenePresetItem
|
||||||
|
{
|
||||||
|
DeviceName = "Lemesh 色温灯",
|
||||||
|
Category = nameof(HealthDeviceCategory.Lighting),
|
||||||
|
EntityId = _options.Entities.AmbientLight,
|
||||||
|
Action = "开启暖光助眠",
|
||||||
|
Detail = $"亮度 {lightBrightness}% · 色温 {lightColorTemp}K",
|
||||||
|
Parameters = new()
|
||||||
|
{
|
||||||
|
["brightness"] = $"{lightBrightness}%",
|
||||||
|
["color_temp"] = $"{lightColorTemp}K"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_options.Entities.Speaker))
|
||||||
|
{
|
||||||
|
var vol = userConfig?.SpeakerVolumePercent ?? 15;
|
||||||
|
presets.Add(new HomeAssistantScenePresetItem
|
||||||
|
{
|
||||||
|
DeviceName = "Xiaomi Sound Pro",
|
||||||
|
Category = nameof(HealthDeviceCategory.Media),
|
||||||
|
EntityId = _options.Entities.Speaker,
|
||||||
|
Action = enableSpeaker ? "开启音箱声音和睡眠模式" : "开启音箱声音",
|
||||||
|
Detail = enableSpeaker ? $"取消静音 · 开启睡眠模式 · 音量 {vol}%" : $"取消静音 · 音量 {vol}%",
|
||||||
|
Parameters = new() { ["muted"] = false, ["sleep_mode"] = enableSpeaker ? "开启" : "关闭", ["volume"] = $"{vol}%" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = $"左右加热 {heatingLevel} 档 · {mattressMode}",
|
||||||
|
Parameters = new()
|
||||||
|
{
|
||||||
|
["heating_level"] = heatingLevel,
|
||||||
|
["mode"] = mattressMode
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var preset in presets)
|
||||||
|
{
|
||||||
|
preset.Available = !string.IsNullOrWhiteSpace(preset.EntityId) &&
|
||||||
|
stateMap.TryGetValue(preset.EntityId, out var state) &&
|
||||||
|
IsAvailable(state);
|
||||||
|
preset.AvailabilityMessage = preset.Available ? "当前可执行" : "当前不可用,执行时跳过";
|
||||||
|
}
|
||||||
|
|
||||||
|
return presets;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsAvailable(HomeAssistantState state)
|
private static bool IsAvailable(HomeAssistantState state)
|
||||||
=> state.State is not ("unavailable" or "unknown");
|
=> state.State is not ("unavailable" or "unknown");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user