This commit is contained in:
fuenmao
2026-10-08 10:16:43 +08:00
parent af596104e2
commit 4a634ef4c2
8 changed files with 261 additions and 25 deletions
@@ -6,6 +6,7 @@ using Furion.DatabaseAccessor;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using SqlSugar;
using System.Globalization;
using System.Security.Claims;
using System.Text.Json;
@@ -51,6 +52,10 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
[HttpGet("/api/health/home-assistant/lights/transition")]
public HomeAssistantLightTransitionOutput GetLightTransitionStatus() => _lightTransition.GetStatus();
[Authorize]
[HttpGet("/api/health/home-assistant/music/transition")]
public TcmMusicTransitionOutput GetMusicTransitionStatus() => _musicTransition.GetStatus();
[Authorize]
[HttpPost("/api/health/home-assistant/lights/sleep")]
public async Task<HomeAssistantLightTransitionOutput> StartSleepLight(CancellationToken cancellationToken)
@@ -500,6 +505,9 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
serviceData["entity_id"] = targetEntityId;
await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken);
if (parameterType is "mattress_left_mode" or "mattress_right_mode")
await VerifyMattressModeAsync(targetEntityId, serviceData["option"]?.ToString() ?? "", serviceData,
cancellationToken);
var successMessage = parameterType switch
{
@@ -584,12 +592,14 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var successCount = actions.Count(item => item.Status == "Succeeded");
var failedCount = actions.Count(item => item.Status == "Failed");
var skippedCount = actions.Count(item => item.Status == "Skipped");
var failedNames = string.Join("、", actions.Where(item => item.Status == "Failed")
.Select(item => item.Name).Distinct().Take(3));
status = failedCount == 0 && successCount > 0 ? "Succeeded" : successCount > 0 ? "Partial" : "Failed";
message = status switch
{
"Succeeded" when skippedCount > 0 => $"场景已执行,{successCount} 项成功,{skippedCount} 项因设备离线或未配置而跳过",
"Succeeded" => $"场景已执行,{successCount} 项成功",
"Partial" => $"场景部分执行成功:{successCount} 项成功,{failedCount} 项失败,{skippedCount} 项跳过",
"Partial" => $"场景部分执行成功:{successCount} 项成功,{failedCount} 项失败,{skippedCount} 项跳过;未完成:{failedNames}",
_ => "当前没有可执行的在线设备"
};
}
@@ -723,7 +733,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
new Dictionary<string, object?> { ["value"] = mattressHeatingLevel }, cancellationToken);
await RunAction(states, results, "设置床垫右侧加热档位", entities.MattressRightHeatingLevel, "number", "set_value",
new Dictionary<string, object?> { ["value"] = mattressHeatingLevel }, cancellationToken);
// 左右承托模式共用气泵,一键场景不同时启动两侧模式。
// 左右承托模式共用气泵;一键场景只实际启动左侧,右侧留作页面预选。
if (mattressFirmness == null)
{
results.Add(new HomeAssistantActionResult
@@ -741,6 +751,25 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
new Dictionary<string, object?> { ["option"] = mattressFirmness }, cancellationToken);
}
}
const string leftModeActionName = "开启床垫左侧智能适配";
await RunAction(states, results, leftModeActionName, entities.MattressLeftMode, "select", "select_option",
new Dictionary<string, object?> { ["option"] = "智能适配" }, cancellationToken);
var leftModeAction = results.LastOrDefault(item => item.Name == leftModeActionName);
if (leftModeAction?.Status == "Succeeded")
{
try
{
await VerifyMattressModeAsync(entities.MattressLeftMode, "智能适配",
new Dictionary<string, object?> { ["entity_id"] = entities.MattressLeftMode, ["option"] = "智能适配" },
cancellationToken);
leftModeAction.Message = "已确认左侧智能适配运行,右侧保持待机预选";
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
leftModeAction.Status = "Failed";
leftModeAction.Message = exception.Message;
}
}
// 软硬度调节可能占用床垫气泵;等这些指令完成后,最后启动按摩。
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
await RunAction(states, results, "停止床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option",
@@ -787,21 +816,119 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
{
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);
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.MattressRightMassagePart, "select", "select_option",
new Dictionary<string, object?> { ["option"] = "无" }, cancellationToken);
await RunAction(states, results, "关闭床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option",
new Dictionary<string, object?> { ["option"] = "无" }, cancellationToken);
var commands = new List<OffCommand>
{
new("关闭除湿机", entities.Dehumidifier, "humidifier", "turn_off", "off", null),
new("关闭香氛机", entities.FragranceSwitch, "switch", "turn_off", "off", null),
new("关闭香氛灯", entities.FragranceLight, "light", "turn_off", "off", null),
new("关闭感应夜灯", entities.FragranceAutoLight, "switch", "turn_off", "off", null),
new("关闭人来增香", entities.FragranceAutoRelease, "switch", "turn_off", "off", null),
new("关闭助眠灯光", entities.AmbientLight, "light", "turn_off", "off", null),
new("关闭音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_off", "off", null),
new("关闭床垫右侧按摩", entities.MattressRightMassagePart, "select", "select_option", "无",
new Dictionary<string, object?> { ["option"] = "无" }),
new("关闭床垫左侧按摩", entities.MattressLeftMassagePart, "select", "select_option", "无",
new Dictionary<string, object?> { ["option"] = "无" })
};
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);
commands.Add(new("关闭床垫左侧加热", entities.MattressLeftHeatingLevel, "number", "set_value", "0",
new Dictionary<string, object?> { ["value"] = 0 }));
commands.Add(new("关闭床垫右侧加热", entities.MattressRightHeatingLevel, "number", "set_value", "0",
new Dictionary<string, object?> { ["value"] = 0 }));
}
foreach (var command in commands)
await RunAction(states, results, command.Name, command.EntityId, command.Domain, command.Service,
command.ExtraData, cancellationToken);
await VerifyOffActionsAsync(commands, results, cancellationToken);
}
private sealed record OffCommand(string Name, string EntityId, string Domain, string Service,
string ExpectedState, IReadOnlyDictionary<string, object?>? ExtraData);
private async Task VerifyOffActionsAsync(
IReadOnlyList<OffCommand> commands,
List<HomeAssistantActionResult> results,
CancellationToken cancellationToken)
{
var pending = commands.Where(item => !string.IsNullOrWhiteSpace(item.EntityId)).ToList();
for (var round = 0; round < 3 && pending.Count > 0; round++)
{
// HA 接受指令并不代表设备已变更,尤其是云端控制的香氛机。
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
IReadOnlyDictionary<string, HomeAssistantState> freshStates;
try
{
freshStates = (await _homeAssistant.GetStatesAsync(cancellationToken))
.ToDictionary(item => item.EntityId, StringComparer.OrdinalIgnoreCase);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
foreach (var command in pending)
{
var action = results.Last(item => item.Name == command.Name);
action.Status = "Failed";
action.Message = $"关闭指令已发送,但无法回读确认:{exception.Message}";
}
return;
}
foreach (var command in pending.ToArray())
{
var action = results.Last(item => item.Name == command.Name);
freshStates.TryGetValue(command.EntityId, out var state);
if (state is not null && IsOffState(state.State, command.ExpectedState))
{
action.Status = "Succeeded";
action.Message = round == 0 ? "已回读确认关闭" : "重试后已回读确认关闭";
pending.Remove(command);
continue;
}
if (round == 2)
{
action.Status = "Failed";
action.Message = $"关闭后仍未确认目标状态(当前:{state?.State ?? "未读取到"})";
pending.Remove(command);
continue;
}
if (state is null || !IsAvailable(state)) continue;
try
{
var data = new Dictionary<string, object?> { ["entity_id"] = command.EntityId };
if (command.ExtraData is not null)
foreach (var item in command.ExtraData) data[item.Key] = item.Value;
await _homeAssistant.CallServiceAsync(command.Domain, command.Service, data, cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
action.Status = "Failed";
action.Message = $"重试关闭失败:{exception.Message}";
}
}
}
}
private static bool IsOffState(string actual, string expected)
=> expected == "0"
? decimal.TryParse(actual, NumberStyles.Any, CultureInfo.InvariantCulture, out var value) && value == 0
: string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
private async Task VerifyMattressModeAsync(string entityId, string expected,
IReadOnlyDictionary<string, object?> serviceData, CancellationToken cancellationToken)
{
for (var attempt = 0; attempt < 3; attempt++)
{
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
var actual = (await _homeAssistant.GetStatesAsync(cancellationToken))
.FirstOrDefault(item => string.Equals(item.EntityId, entityId, StringComparison.OrdinalIgnoreCase))?.State;
if (string.Equals(actual, expected, StringComparison.Ordinal)) return;
if (attempt < 2)
await _homeAssistant.CallServiceAsync("select", "select_option", serviceData, cancellationToken);
else
throw new InvalidOperationException($"床垫承托模式未确认切换到{expected}(当前:{actual ?? "未读取到"})");
}
}
@@ -1423,8 +1550,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
Category = nameof(HealthDeviceCategory.Media),
EntityId = _options.Entities.Speaker,
Action = enableSpeaker ? "播放体质音乐并开启睡眠模式" : "播放体质音乐",
Detail = enableSpeaker ? "体质曲目顺序播放 · 音量60%每5秒降1% · 开启睡眠模式" : "体质曲目顺序播放 · 音量60%每5秒降1%",
Parameters = new() { ["music"] = "四诊体质专属曲目", ["sleep_mode"] = enableSpeaker ? "开启" : "关闭", ["volume"] = "60%→0%" }
Detail = enableSpeaker ? "体质曲目循环播放 · 音量50%每5秒降1% · 开启睡眠模式" : "体质曲目循环播放 · 音量50%每5秒降1%",
Parameters = new() { ["music"] = "四诊体质专属曲目", ["sleep_mode"] = enableSpeaker ? "开启" : "关闭", ["volume"] = "50%→0%" }
});
}
@@ -139,7 +139,12 @@ public static class TcmConstitutionRules
try
{
var result = JsonSerializer.Deserialize<TcmAssessmentResult>(profile.TcmResultJson) ?? new TcmAssessmentResult();
if (result.HasCompleted) result.MusicRecommendation = MusicFor(result.ConstitutionCode);
if (result.HasCompleted)
{
result.MusicRecommendation = MusicFor(result.ConstitutionCode);
result.SceneConfig ??= new PersonalizedSceneConfig();
result.SceneConfig.SpeakerVolumePercent = 50;
}
return result;
}
catch { return new TcmAssessmentResult(); }
@@ -209,7 +214,7 @@ public static class TcmConstitutionRules
MattressHeatingLevel = 0, MattressLeftFirmness = 0, MattressRightFirmness = 0,
MattressMode = "智能适配",
EnableFragrance = fragrance && input.Answers["allergy"] != "often", FragranceLevel = 1,
EnableSpeakerSleepMode = true, SpeakerVolumePercent = 60
EnableSpeakerSleepMode = true, SpeakerVolumePercent = 50
}
};
}