This commit is contained in:
fuenmao
2026-09-23 15:30:40 +08:00
parent f61b3ce3e7
commit db7bd2231a
6 changed files with 471 additions and 10 deletions
@@ -14,7 +14,10 @@ namespace Evergreen.Health.Application.Health.Services;
public sealed class HealthHomeAssistantService : IDynamicApiController, ITransient
{
private const string HealthUserIdClaim = "health_user_id";
private const int PlayMediaFeature = 512;
private const string DemoAudioUrl = "https://oss.myzxjgt.com/App.Live/uploads/%E9%9F%B3%E9%A2%91/2026-05-29/57a6a64d8dfa4a1bb876a15bb8fdfd24.mp3";
private readonly HomeAssistantClient _homeAssistant;
private readonly HomeAssistantLightTransitionService _lightTransition;
private readonly HomeAssistantRegistryService _registryService;
private readonly HomeAssistantOptions _options;
private readonly SqlSugarRepository<HealthSceneExecution> _executionRepository;
@@ -23,6 +26,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
public HealthHomeAssistantService(
HomeAssistantClient homeAssistant,
HomeAssistantLightTransitionService lightTransition,
HomeAssistantRegistryService registryService,
IOptions<HomeAssistantOptions> options,
SqlSugarRepository<HealthSceneExecution> executionRepository,
@@ -30,6 +34,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
IHttpContextAccessor httpContextAccessor)
{
_homeAssistant = homeAssistant;
_lightTransition = lightTransition;
_registryService = registryService;
_options = options.Value;
_executionRepository = executionRepository;
@@ -37,6 +42,97 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
_httpContextAccessor = httpContextAccessor;
}
[Authorize]
[HttpGet("/api/health/home-assistant/lights/transition")]
public HomeAssistantLightTransitionOutput GetLightTransitionStatus() => _lightTransition.GetStatus();
[Authorize]
[HttpPost("/api/health/home-assistant/lights/sleep")]
public async Task<HomeAssistantLightTransitionOutput> StartSleepLight(CancellationToken cancellationToken)
{
try { return await _lightTransition.StartSleepAsync(_options.SleepScene.LightColorTemperatureKelvin, cancellationToken); }
catch (Exception exception) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; }
}
[Authorize]
[HttpPost("/api/health/home-assistant/lights/wake")]
public async Task<HomeAssistantLightTransitionOutput> StartWakeLight(CancellationToken cancellationToken)
{
try { return await _lightTransition.StartWakeAsync(cancellationToken); }
catch (Exception exception) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; }
}
[Authorize]
[HttpPost("/api/health/home-assistant/lights/stop")]
public async Task<HomeAssistantLightTransitionOutput> StopLightTransition(CancellationToken cancellationToken)
{
try { return await _lightTransition.CancelAsync(cancellationToken); }
catch (Exception exception) { return new HomeAssistantLightTransitionOutput { Success = false, Message = exception.Message }; }
}
[Authorize]
[HttpGet("/api/health/home-assistant/speaker/demo")]
public async Task<HomeAssistantSpeakerDemoOutput> GetSpeakerDemo(CancellationToken cancellationToken)
{
var result = new HomeAssistantSpeakerDemoOutput { AudioUrl = DemoAudioUrl };
try
{
var entityId = _options.Entities.Speaker;
if (string.IsNullOrWhiteSpace(entityId))
{
result.Message = "尚未配置音箱实体";
return result;
}
var state = (await _homeAssistant.GetStatesAsync(cancellationToken))
.FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase));
result.SpeakerOnline = state is not null && IsAvailable(state);
if (!result.SpeakerOnline)
{
result.Message = "音箱当前离线或不可用";
return result;
}
var features = (long)(state!.GetDoubleAttribute("supported_features") ?? 0);
result.DirectPlaybackSupported = (features & PlayMediaFeature) != 0;
result.Message = result.DirectPlaybackSupported
? "音箱声明支持指定音频播放,可以尝试 OSS 示例音频"
: "当前 Xiaomi Home 音箱实体不支持播放指定音频;OSS 链接可在浏览器试听,但暂不能投放到音箱";
}
catch (Exception exception)
{
result.Message = $"音箱能力检测失败:{exception.Message}";
}
return result;
}
[Authorize]
[HttpPost("/api/health/home-assistant/speaker/play-demo")]
public async Task<HomeAssistantSpeakerDemoOutput> PlaySpeakerDemo(CancellationToken cancellationToken)
{
var result = await GetSpeakerDemo(cancellationToken);
if (!result.SpeakerOnline || !result.DirectPlaybackSupported) return result;
try
{
await _homeAssistant.CallServiceAsync("media_player", "play_media", new Dictionary<string, object?>
{
["entity_id"] = _options.Entities.Speaker,
["media_content_id"] = DemoAudioUrl,
["media_content_type"] = "music"
}, cancellationToken);
result.CommandAccepted = true;
result.Message = "播放指令已发送,请确认音箱是否实际出声;接口接受不等于播放成功";
}
catch (Exception exception)
{
result.Message = $"音箱未接受播放指令:{exception.Message}";
}
return result;
}
[Authorize]
[HttpGet("/api/health/home-assistant/status")]
public async Task<HomeAssistantStatusOutput> GetStatus(CancellationToken cancellationToken)
@@ -172,6 +268,24 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
};
}
if (domain == "light" && string.Equals(targetEntityId, _options.Entities.AmbientLight, StringComparison.OrdinalIgnoreCase))
{
if (input.TurnOn)
{
var transition = await _lightTransition.StartSleepAsync(2700, cancellationToken);
return new HomeAssistantDeviceSwitchOutput
{
Success = transition.Success,
DeviceId = input.DeviceId,
EntityId = targetEntityId,
State = "on",
Message = transition.Message
};
}
await _lightTransition.CancelAsync(cancellationToken);
}
var targetOn = input.TurnOn;
bool? targetMuted = null;
var service = targetOn ? "turn_on" : "turn_off";
@@ -354,6 +468,45 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
domain = "switch";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
case "fragrance_duration":
targetEntityId = _options.Entities.FragranceDuration;
domain = "number";
service = "set_value";
serviceData["value"] = ReadInt(input.Value, 2, 6, "出香时长");
break;
case "fragrance_interval":
targetEntityId = _options.Entities.FragranceInterval;
domain = "number";
service = "set_value";
var fragranceInterval = ReadInt(input.Value, 10, 20, "出香间隔");
if (fragranceInterval % 5 != 0) throw new InvalidOperationException("出香间隔仅支持 10、15 或 20 分钟");
serviceData["value"] = fragranceInterval;
break;
case "fragrance_light":
targetEntityId = _options.Entities.FragranceLight;
domain = "light";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
case "fragrance_quick_release":
targetEntityId = _options.Entities.FragranceQuickRelease;
domain = "button";
service = "press";
break;
case "fragrance_auto_release":
targetEntityId = _options.Entities.FragranceAutoRelease;
domain = "switch";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
case "fragrance_auto_light":
targetEntityId = _options.Entities.FragranceAutoLight;
domain = "switch";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
case "fragrance_light_auto_off":
targetEntityId = _options.Entities.FragranceLightAutoOff;
domain = "switch";
service = ReadBool(input.Value) ? "turn_on" : "turn_off";
break;
case "mattress_left_heating":
targetEntityId = _options.Entities.MattressLeftHeatingLevel;
domain = "number";
@@ -403,6 +556,9 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
if (!stateMap.TryGetValue(targetEntityId, out var targetState) || !IsAvailable(targetState))
throw new InvalidOperationException("目标参数实体当前离线或不可用");
if (parameterType is "brightness" or "color_temp")
await _lightTransition.CancelAsync(cancellationToken);
serviceData["entity_id"] = targetEntityId;
await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken);
@@ -442,6 +598,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var successMessage = parameterType switch
{
"fragrance_quick_release" => "快速出香指令已发送",
"mattress_left_mode" when !string.Equals(serviceData["option"]?.ToString(), "无", StringComparison.Ordinal) =>
"左侧承托模式已启动;床垫共用气泵,右侧会自动停止",
"mattress_right_mode" when !string.Equals(serviceData["option"]?.ToString(), "无", StringComparison.Ordinal) =>
@@ -572,6 +729,9 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var lightBrightness = customConfig?.LightBrightnessPercent ?? scene.LightBrightnessPercent;
var lightColorTemp = customConfig?.LightColorTemperatureKelvin ?? scene.LightColorTemperatureKelvin;
var enableFragrance = customConfig?.EnableFragrance ?? scene.EnableFragrance;
var fragranceLevel = Math.Clamp(customConfig?.FragranceLevel ?? 1, 1, 3);
var fragranceDuration = fragranceLevel switch { 1 => 2, 2 => 4, _ => 6 };
var fragranceInterval = fragranceLevel switch { 1 => 20, 2 => 15, _ => 10 };
var enableSpeakerSleepMode = customConfig?.EnableSpeakerSleepMode ?? scene.EnableSpeakerSleepMode;
var speakerVolume = customConfig?.SpeakerVolumePercent ?? 15;
var mattressHeatingLevel = customConfig?.MattressHeatingLevel ?? scene.MattressHeatingLevel;
@@ -590,16 +750,43 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
if (enableFragrance)
{
await RunAction(states, results, "开启香氛机", entities.FragranceSwitch, "switch", "turn_on", null, cancellationToken);
await RunAction(states, results, "设置出香时长", entities.FragranceDuration, "number", "set_value",
new Dictionary<string, object?> { ["value"] = fragranceDuration }, cancellationToken);
await RunAction(states, results, "设置出香间隔", entities.FragranceInterval, "number", "set_value",
new Dictionary<string, object?> { ["value"] = fragranceInterval }, cancellationToken);
await RunAction(states, results, "开启香氛灯", entities.FragranceLight, "light", "turn_on",
new Dictionary<string, object?> { ["brightness_pct"] = lightBrightness }, cancellationToken);
}
await RunAction(states, results, "开启助眠灯光", entities.AmbientLight, "light", "turn_on",
new Dictionary<string, object?>
if (string.IsNullOrWhiteSpace(entities.AmbientLight) ||
!states.TryGetValue(entities.AmbientLight, out var lightState) || !IsAvailable(lightState))
{
results.Add(new HomeAssistantActionResult
{
["brightness_pct"] = lightBrightness,
["color_temp_kelvin"] = lightColorTemp
}, cancellationToken);
Name = "开启助眠渐暗灯光", EntityId = entities.AmbientLight,
Status = "Skipped", Message = "设备未配置或离线"
});
}
else
{
try
{
await _lightTransition.StartSleepAsync(lightColorTemp, cancellationToken);
results.Add(new HomeAssistantActionResult
{
Name = "开启助眠渐暗灯光", EntityId = entities.AmbientLight,
Status = "Succeeded", Message = "已从50%开始,每5秒降低1%,最终关灯"
});
}
catch (Exception exception)
{
results.Add(new HomeAssistantActionResult
{
Name = "开启助眠渐暗灯光", EntityId = entities.AmbientLight,
Status = "Failed", Message = exception.Message
});
}
}
if (enableSpeakerSleepMode)
{
@@ -635,6 +822,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
CancellationToken cancellationToken)
{
var entities = _options.Entities;
await _lightTransition.CancelAsync(cancellationToken);
await RunAction(states, results, "关闭除湿机", entities.Dehumidifier, "humidifier", "turn_off", null, cancellationToken);
await RunAction(states, results, "关闭香氛机", entities.FragranceSwitch, "switch", "turn_off", null, cancellationToken);
await RunAction(states, results, "关闭香氛灯", entities.FragranceLight, "light", "turn_off", null, cancellationToken);
@@ -835,7 +1023,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
AddFallback("fallback_dehumidifier", "米家变频净化除湿机 Max", "xiaomi.derh.60l", nameof(HealthDeviceCategory.EnvironmentControl), "睡眠空间",
entities.Dehumidifier, entities.DehumidifierFanLevel, entities.DehumidifierUv);
AddFallback("fallback_fragrance", "米家智能香氛机", "xiaomi.diffuser.xw002", nameof(HealthDeviceCategory.Fragrance), "睡眠空间",
entities.FragranceSwitch, entities.FragranceLight);
entities.FragranceSwitch, entities.FragranceLight, entities.FragranceDuration, entities.FragranceInterval,
entities.FragranceQuickRelease, entities.FragranceAutoRelease, entities.FragranceAutoLight, entities.FragranceLightAutoOff);
AddFallback("fallback_ambient_light", "米家夜灯/氛围灯", "lemesh.light.wy0d02", nameof(HealthDeviceCategory.Lighting), "睡眠空间",
entities.AmbientLight);
AddFallback("fallback_speaker", "Xiaomi Sound Pro 智能音箱", "xiaomi.wifispeaker.l17a", nameof(HealthDeviceCategory.Media), "睡眠空间",
@@ -1069,6 +1258,13 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
["color_temp"] = _options.Entities.AmbientLight,
["volume"] = _options.Entities.Speaker,
["speaker_sleep_mode"] = _options.Entities.SpeakerSleepMode,
["fragrance_duration"] = _options.Entities.FragranceDuration,
["fragrance_interval"] = _options.Entities.FragranceInterval,
["fragrance_light"] = _options.Entities.FragranceLight,
["fragrance_quick_release"] = _options.Entities.FragranceQuickRelease,
["fragrance_auto_release"] = _options.Entities.FragranceAutoRelease,
["fragrance_auto_light"] = _options.Entities.FragranceAutoLight,
["fragrance_light_auto_off"] = _options.Entities.FragranceLightAutoOff,
["mattress_left_heating"] = _options.Entities.MattressLeftHeatingLevel,
["mattress_right_heating"] = _options.Entities.MattressRightHeatingLevel,
["mattress_left_mode"] = _options.Entities.MattressLeftMode,
@@ -1132,7 +1328,6 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
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;
@@ -1177,11 +1372,11 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
DeviceName = "Lemesh 色温灯",
Category = nameof(HealthDeviceCategory.Lighting),
EntityId = _options.Entities.AmbientLight,
Action = "开启暖光助眠",
Detail = $"亮度 {lightBrightness}% · 色温 {lightColorTemp}K",
Action = "开启助眠渐暗灯光",
Detail = $"从50%开始,每5秒降低1%,最终关灯 · 色温 {lightColorTemp}K",
Parameters = new()
{
["brightness"] = $"{lightBrightness}%",
["brightness"] = "50%→关闭",
["color_temp"] = $"{lightColorTemp}K"
}
});