This commit is contained in:
fuenmao
2026-10-09 16:46:30 +08:00
parent 4a634ef4c2
commit e6b90a06ab
63 changed files with 4170 additions and 1518 deletions
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
using Evergreen.Health.Application.Health.Entities;
using Evergreen.Health.Application.Health.Models;
using Evergreen.Health.Application.Health.Policies;
using Furion.DatabaseAccessor;
using Furion.FriendlyException;
using Microsoft.AspNetCore.Http;
@@ -144,6 +145,7 @@ public static class TcmConstitutionRules
result.MusicRecommendation = MusicFor(result.ConstitutionCode);
result.SceneConfig ??= new PersonalizedSceneConfig();
result.SceneConfig.SpeakerVolumePercent = 50;
result.RoomTemperatureRecommendation = RecommendedRoomRange.ForConstitution(result.ConstitutionCode).Display;
}
return result;
}
@@ -202,7 +204,7 @@ public static class TcmConstitutionRules
AromaRecommendation = input.Answers["allergy"] == "often"
? "建议不使用香氛,避免过敏原" : aroma,
MusicRecommendation = music,
RoomTemperatureRecommendation = room,
RoomTemperatureRecommendation = RecommendedRoomRange.ForConstitution(primary).Display,
Scores = scores,
Answers = Choices.Keys.ToDictionary(key => key, key => input.Answers[key]),
Notes = notes,
@@ -1,65 +1,54 @@
using Evergreen.Health.Application.Health.Devices.Environment;
using Evergreen.Health.Application.Health.Entities;
using Evergreen.Health.Application.Health.Integrations.HomeAssistant;
using Evergreen.Health.Application.Health.Models;
using System.Text.Json;
using Evergreen.Health.Application.Health.Policies;
using Microsoft.Extensions.Options;
namespace Evergreen.Health.Application.Health.Services;
// 重庆演示场景:BMI 只给出可手动修改的软硬起点,不是医学或人体工学诊断。
/// <summary>组合独立的 BMI 和室温策略,不负责发送家具控制命令。</summary>
public sealed class MattressComfortService : ITransient
{
// 演示使用 Open-Meteo 接口;正式商用前应确认许可或替换为有授权的天气源。
private const string WeatherUrl =
"https://api.open-meteo.com/v1/forecast?latitude=29.56&longitude=106.55&current=temperature_2m%2Capparent_temperature&timezone=Asia%2FShanghai";
private static readonly HttpClient WeatherClient = new() { Timeout = TimeSpan.FromSeconds(6) };
private static readonly SemaphoreSlim WeatherLock = new(1, 1);
private static WeatherSample? CachedWeather;
private readonly IHomeAssistantGateway _gateway;
private readonly IndoorEnvironmentReader _environment;
private readonly HomeAssistantEntityOptions _entities;
public MattressComfortService(IHomeAssistantGateway gateway, IndoorEnvironmentReader environment,
IOptions<HomeAssistantOptions> options)
=> (_gateway, _environment, _entities) = (gateway, environment, options.Value.Entities);
public async Task<MattressComfortOutput> GetAsync(HealthProfile? profile, CancellationToken cancellationToken = default)
{
decimal? bmi = null;
if (profile?.HeightCm > 0 && profile.WeightKg > 0)
IReadOnlyDictionary<string, HomeAssistantState> states;
try
{
var height = profile.HeightCm.Value / 100m;
bmi = Math.Round(profile.WeightKg.Value / (height * height), 1);
states = (await _gateway.GetStatesAsync(cancellationToken)).GroupBy(state => state.EntityId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase);
}
var firmness = bmi switch
catch (Exception exception) when (exception is not OperationCanceledException)
{
< 18.5m => "很软",
< 24m => "稍软",
< 28m => "稍硬",
< 32m => "很硬",
>= 32m => "最硬",
_ => null
};
var chinaNow = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8));
var winter = chinaNow.Month is 12 or 1 or 2;
var weather = await ReadWeatherAsync(cancellationToken);
var heating = 0;
if (winter && weather?.FeelsLikeCelsius is double feelsLike)
heating = feelsLike < 8 ? 2 : feelsLike < 16 ? 1 : 0;
states = new Dictionary<string, HomeAssistantState>();
}
return Get(profile, states);
}
var weatherReason = !winter
? "重庆非冬季,床垫加热关闭"
: weather == null
? "重庆天气暂不可用,为安全起见不自动加热"
: heating == 0
? "重庆处于冬季,但当前体感温度较高,不自动加热"
: $"重庆冬季体感 {weather.FeelsLikeCelsius:0.#}℃,建议加热 {heating} 档";
return new MattressComfortOutput
public MattressComfortOutput Get(HealthProfile? profile, IReadOnlyDictionary<string, HomeAssistantState> states)
{
var bmi = BmiFirmnessPolicy.Calculate(profile?.HeightCm, profile?.WeightKg);
var firmness = BmiFirmnessPolicy.Firmness(bmi);
var tcm = TcmConstitutionRules.Read(profile);
var sample = _environment.Read(states);
var advice = RoomTemperaturePolicy.Evaluate(sample, RecommendedRoomRange.ForConstitution(tcm.ConstitutionCode));
advice.PendingIntegration = (advice.Mode is "heat" or "cool") && string.IsNullOrWhiteSpace(_entities.Climate);
return new()
{
Bmi = bmi,
Firmness = firmness,
HeatingLevel = heating,
IsWinter = winter,
OutdoorTemperatureCelsius = weather?.TemperatureCelsius,
FeelsLikeCelsius = weather?.FeelsLikeCelsius,
WeatherAvailable = weather != null,
WeatherObservedAt = weather?.ObservedAt,
WeatherReason = weatherReason,
FirmnessReason = firmness == null
? "健康档案尚无身高和体重,暂不自动调节软硬度"
Bmi = bmi, Firmness = firmness, HeatingLevel = advice.Mode == "heat" ? 1 : 0,
IndoorTemperatureCelsius = sample.Celsius, IndoorTemperatureAvailable = sample.Available,
IndoorTemperatureObservedAt = sample.ObservedAt, HeatingReason = advice.Reason,
WeatherReason = advice.Reason, // 旧客户端兼容字段,不再读取或依赖室外天气。
ClimateAdvice = advice,
FirmnessReason = firmness is null
? "健康档案尚无有效身高和体重,暂不自动调节软硬度"
: $"按 BMI {bmi:0.0} 给出“{firmness}”演示起点,可在设备详情手动调整"
};
}
@@ -67,61 +56,18 @@ public sealed class MattressComfortService : ITransient
public static void ApplyTo(PersonalizedSceneConfig config, MattressComfortOutput recommendation)
{
config.MattressHeatingLevel = recommendation.HeatingLevel;
config.MattressLeftFirmness = recommendation.Firmness == null ? 0 : FirmnessLevel(recommendation.Firmness);
config.MattressLeftFirmness = recommendation.Firmness is null ? 0 : FirmnessLevel(recommendation.Firmness);
config.MattressRightFirmness = config.MattressLeftFirmness;
config.ClimateAdvice = recommendation.ClimateAdvice;
}
public static int FirmnessLevel(string firmness) => firmness switch
{
"最软" => 1, "很软" => 2, "稍软" => 3,
"稍硬" => 4, "很硬" => 5, "最硬" => 6,
_ => 0
"最软" => 1, "很软" => 2, "稍软" => 3, "稍硬" => 4, "很硬" => 5, "最硬" => 6, _ => 0
};
public static string? FirmnessName(int level) => level switch
{
1 => "最软", 2 => "很软", 3 => "稍软",
4 => "稍硬", 5 => "很硬", 6 => "最硬",
_ => null
1 => "最软", 2 => "很软", 3 => "稍软", 4 => "稍硬", 5 => "很硬", 6 => "最硬", _ => null
};
private static async Task<WeatherSample?> ReadWeatherAsync(CancellationToken cancellationToken)
{
if (CachedWeather is { } cached && DateTimeOffset.UtcNow - cached.FetchedAt < TimeSpan.FromMinutes(30))
return cached;
await WeatherLock.WaitAsync(cancellationToken);
try
{
if (CachedWeather is { } fresh && DateTimeOffset.UtcNow - fresh.FetchedAt < TimeSpan.FromMinutes(30))
return fresh;
using var response = await WeatherClient.GetAsync(WeatherUrl, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
var current = json.RootElement.GetProperty("current");
var temperature = current.GetProperty("temperature_2m").GetDouble();
var feelsLike = current.GetProperty("apparent_temperature").GetDouble();
var observedAt = current.GetProperty("time").GetString();
CachedWeather = new WeatherSample(temperature, feelsLike, observedAt, DateTimeOffset.UtcNow);
return CachedWeather;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
// 天气失败时不使用过期值自动开启加热,冬季也安全关闭。
return null;
}
finally
{
WeatherLock.Release();
}
}
private sealed record WeatherSample(
double TemperatureCelsius, double FeelsLikeCelsius, string? ObservedAt, DateTimeOffset FetchedAt);
}