This commit is contained in:
fuenmao
2026-09-22 14:16:49 +08:00
parent 2d05b6f864
commit a063be61cd
4 changed files with 335 additions and 30 deletions
@@ -22,6 +22,10 @@ public sealed class HomeAssistantEntityOptions
public string AmbientLight { get; set; } = string.Empty;
public string Speaker { 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 Humidity { 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 bool EnableFragrance { get; set; } = true;
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; }
}
@@ -32,6 +32,7 @@ public sealed class HomeAssistantDiscoveredDeviceOutput
public string? AreaName { get; set; }
public string Category { get; set; } = nameof(HealthDeviceCategory.Other);
public bool Online { get; set; }
public string? PrimaryControlEntityId { get; set; }
public List<HomeAssistantEntityOutput> Entities { get; set; } = [];
public Dictionary<string, object?> Metrics { get; set; } = [];
}
@@ -71,6 +72,35 @@ public sealed class HomeAssistantStatusOutput
public List<HomeAssistantDiscoveredDeviceOutput> Devices { get; set; } = [];
public HomeAssistantSceneDevicesSummaryOutput SceneDevices { get; set; } = new();
public HomeAssistantEnvironmentOutput Environment { get; set; } = new();
public List<HomeAssistantScenePresetItem> ScenePresets { 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 string Message { get; set; } = string.Empty;
}
public sealed class HomeAssistantSceneOutput
@@ -45,9 +45,10 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var registries = await _registryService.GetRegistriesAsync(cancellationToken);
var discoveredDevices = BuildDiscoveredDevices(registries, stateMap);
var summary = BuildSummary(discoveredDevices, states);
var summary = BuildSummary(discoveredDevices);
var sceneDevices = BuildSceneDevicesSummary(stateMap);
var environment = BuildEnvironmentOutput(stateMap, discoveredDevices);
var scenePresets = BuildScenePresets(stateMap);
return new HomeAssistantStatusOutput
{
@@ -57,7 +58,8 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
Summary = summary,
Devices = discoveredDevices,
SceneDevices = sceneDevices,
Environment = environment
Environment = environment,
ScenePresets = scenePresets
};
}
catch (Exception exception)
@@ -69,8 +71,121 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
CheckedAt = DateTimeOffset.Now,
Summary = new HomeAssistantSummaryOutput(),
Devices = [],
SceneDevices = new HomeAssistantSceneDevicesSummaryOutput { Total = 4, Available = 0 },
Environment = new HomeAssistantEnvironmentOutput()
SceneDevices = BuildSceneDevicesSummary(new Dictionary<string, HomeAssistantState>(StringComparer.OrdinalIgnoreCase)),
Environment = new HomeAssistantEnvironmentOutput(),
ScenePresets = BuildScenePresets(new Dictionary<string, HomeAssistantState>(StringComparer.OrdinalIgnoreCase))
};
}
}
[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 service = input.TurnOn ? "turn_on" : "turn_off";
if (domain == "media_player")
{
service = input.TurnOn ? "media_play" : "media_pause";
}
var serviceData = new Dictionary<string, object?>
{
["entity_id"] = targetEntityId
};
await _homeAssistant.CallServiceAsync(domain, service, serviceData, cancellationToken);
return new HomeAssistantDeviceSwitchOutput
{
Success = true,
DeviceId = input.DeviceId,
EntityId = targetEntityId,
State = domain == "media_player"
? (input.TurnOn ? "playing" : "paused")
: (input.TurnOn ? "on" : "off"),
Message = domain == "media_player"
? $"已成功{(input.TurnOn ? "开始播放" : "暂停播放")}"
: $"已成功{(input.TurnOn ? "开启" : "关闭")}设备"
};
}
catch (Exception ex)
{
return new HomeAssistantDeviceSwitchOutput
{
Success = false,
DeviceId = input.DeviceId,
EntityId = input.EntityId ?? string.Empty,
Message = $"控制失败:{ex.Message}"
};
}
}
@@ -180,6 +295,18 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
if (scene.EnableSpeakerSleepMode)
await RunAction(states, results, "开启音箱睡眠模式", entities.SpeakerSleepMode, "switch", "turn_on", null, cancellationToken);
if (scene.EnableMattress)
{
await RunAction(states, results, "设置床垫左侧低档加热", entities.MattressLeftHeatingLevel, "number", "set_value",
new Dictionary<string, object?> { ["value"] = scene.MattressHeatingLevel }, cancellationToken);
await RunAction(states, results, "设置床垫右侧低档加热", entities.MattressRightHeatingLevel, "number", "set_value",
new Dictionary<string, object?> { ["value"] = scene.MattressHeatingLevel }, cancellationToken);
await RunAction(states, results, "设置床垫左侧智能适配", entities.MattressLeftMode, "select", "select_option",
new Dictionary<string, object?> { ["option"] = scene.MattressMode }, cancellationToken);
await RunAction(states, results, "设置床垫右侧智能适配", entities.MattressRightMode, "select", "select_option",
new Dictionary<string, object?> { ["option"] = scene.MattressMode }, cancellationToken);
}
}
private async Task ExecuteAllOffActions(
@@ -193,6 +320,13 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
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);
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(
@@ -263,7 +397,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
|| (device.Model?.Contains("xiaomi", StringComparison.OrdinalIgnoreCase) == true)
|| regEntities.Any(e => string.Equals(e.Platform, "xiaomi_home", StringComparison.OrdinalIgnoreCase));
if (!isXiaomi && regEntities.Count == 0)
if (!isXiaomi)
{
continue;
}
@@ -302,10 +436,11 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var areaName = !string.IsNullOrWhiteSpace(device.AreaId) && areaMap.TryGetValue(device.AreaId, out var an)
? an
: "睡眠空间";
: null;
var category = CategorizeDevice(displayName, device.Model ?? "", device.Manufacturer ?? "", entityOutputs);
var metrics = ExtractDeviceMetrics(category, entityOutputs);
var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs);
discovered.Add(new HomeAssistantDiscoveredDeviceOutput
{
@@ -316,6 +451,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
AreaName = areaName,
Category = category,
Online = isOnline,
PrimaryControlEntityId = primaryControlEntityId,
Entities = entityOutputs,
Metrics = metrics
});
@@ -357,6 +493,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
var online = entityOutputs.Any(e => e.Available);
var metrics = ExtractDeviceMetrics(category, entityOutputs);
var primaryControlEntityId = FindPrimaryControlEntityId(category, entityOutputs);
list.Add(new HomeAssistantDiscoveredDeviceOutput
{
@@ -367,6 +504,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
AreaName = area,
Category = category,
Online = online,
PrimaryControlEntityId = primaryControlEntityId,
Entities = entityOutputs,
Metrics = metrics
});
@@ -388,15 +526,42 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
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(
IReadOnlyList<HomeAssistantDiscoveredDeviceOutput> devices,
IReadOnlyList<HomeAssistantState> states)
IReadOnlyList<HomeAssistantDiscoveredDeviceOutput> devices)
{
var totalDevices = devices.Count;
var onlineDevices = devices.Count(d => d.Online);
var offlineDevices = totalDevices - onlineDevices;
var totalEntities = states.Count;
var availableEntities = states.Count(IsAvailable);
var totalEntities = devices.Sum(d => d.Entities.Count);
var availableEntities = devices.Sum(d => d.Entities.Count(e => e.Available));
return new HomeAssistantSummaryOutput
{
@@ -411,30 +576,27 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
private HomeAssistantSceneDevicesSummaryOutput BuildSceneDevicesSummary(IReadOnlyDictionary<string, HomeAssistantState> stateMap)
{
var entities = _options.Entities;
var sceneAvailableCount = 0;
var sceneEntityIds = new List<string>();
if (!string.IsNullOrWhiteSpace(entities.Dehumidifier) &&
stateMap.TryGetValue(entities.Dehumidifier, out var sDehum) && IsAvailable(sDehum))
sceneAvailableCount++;
if (!string.IsNullOrWhiteSpace(entities.Dehumidifier))
sceneEntityIds.Add(entities.Dehumidifier);
if (_options.SleepScene.EnableFragrance && !string.IsNullOrWhiteSpace(entities.FragranceSwitch))
sceneEntityIds.Add(entities.FragranceSwitch);
if (!string.IsNullOrWhiteSpace(entities.AmbientLight))
sceneEntityIds.Add(entities.AmbientLight);
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) &&
stateMap.TryGetValue(entities.FragranceSwitch, out var sFrag) && IsAvailable(sFrag))
sceneAvailableCount++;
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++;
sceneEntityIds = sceneEntityIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var sceneAvailableCount = sceneEntityIds.Count(entityId =>
stateMap.TryGetValue(entityId, out var state) && IsAvailable(state));
return new HomeAssistantSceneDevicesSummaryOutput
{
Total = 4,
Available = Math.Min(4, sceneAvailableCount)
Total = sceneEntityIds.Count,
Available = sceneAvailableCount
};
}
@@ -470,6 +632,7 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
Temperature = GetVal(entities.Temperature, "temperature"),
Humidity = humidity,
Pm25 = GetVal(entities.Pm25, "pm25"),
Pm10 = GetVal(string.Empty, "pm10"),
Co2 = GetVal(entities.Co2, "co2"),
Hcho = GetVal(entities.Formaldehyde, "hcho"),
Tvoc = GetVal(entities.Tvoc, "tvoc")
@@ -570,6 +733,104 @@ public sealed class HealthHomeAssistantService : IDynamicApiController, ITransie
return safe.Count > 0 ? safe : null;
}
private List<HomeAssistantScenePresetItem> BuildScenePresets(
IReadOnlyDictionary<string, HomeAssistantState> stateMap)
{
var scene = _options.SleepScene;
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 = $"目标湿度 {scene.TargetHumidity}% · {scene.DehumidifierMode}模式 · 风速{scene.FanLevel} · UV{(scene.EnableUv ? "开" : "关")}",
Parameters = new()
{
["target_humidity"] = $"{scene.TargetHumidity}%",
["mode"] = scene.DehumidifierMode,
["fan_level"] = scene.FanLevel,
["uv"] = scene.EnableUv ? "开启" : "关闭"
}
});
}
if (scene.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 = $"亮度 {scene.LightBrightnessPercent}% · 色温 {scene.LightColorTemperatureKelvin}K",
Parameters = new()
{
["brightness"] = $"{scene.LightBrightnessPercent}%",
["color_temp"] = $"{scene.LightColorTemperatureKelvin}K"
}
});
}
if (scene.EnableSpeakerSleepMode && !string.IsNullOrWhiteSpace(_options.Entities.SpeakerSleepMode))
{
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "Xiaomi Sound Pro",
Category = nameof(HealthDeviceCategory.Media),
EntityId = _options.Entities.SpeakerSleepMode,
Action = "开启音箱睡眠模式",
Detail = "仅切换已确认的睡眠模式开关,不自动播放未知媒体内容",
Parameters = new() { ["sleep_mode"] = "开启" }
});
}
if (scene.EnableMattress && !string.IsNullOrWhiteSpace(_options.Entities.MattressLeftHeatingLevel))
{
presets.Add(new HomeAssistantScenePresetItem
{
DeviceName = "8H AI智能好睡眠床垫X",
Category = nameof(HealthDeviceCategory.Sleep),
EntityId = _options.Entities.MattressLeftHeatingLevel,
Action = "开启低档加热与智能适配",
Detail = $"左右加热 {scene.MattressHeatingLevel} 档 · 左右气袋模式 {scene.MattressMode}",
Parameters = new()
{
["left_heating_level"] = scene.MattressHeatingLevel,
["right_heating_level"] = scene.MattressHeatingLevel,
["left_mode"] = scene.MattressMode,
["right_mode"] = scene.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)
=> state.State is not ("unavailable" or "unknown");
}