using Evergreen.Health.Application.Health.Devices; using Evergreen.Health.Application.Health.Devices.Climate; using Evergreen.Health.Application.Health.Devices.Discovery; using Evergreen.Health.Application.Health.Integrations.HomeAssistant; using Evergreen.Health.Application.Health.Models; using Evergreen.Health.Application.Health.Scenes; using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; using System.Text.Json; using Xunit; using SystemJson = System.Text.Json.JsonSerializer; namespace Admin.NET.Test.Health; /// 检查 HA JSON 到 API JSON 的完整转换,不连接 HA、不发送家具指令。 public sealed class HomeAssistantAttributeSerializationTests { private const string ClimateId = "climate.test_room"; private static HomeAssistantState State(string state = "off") => new() { EntityId = ClimateId, State = state, Attributes = SystemJson.SerializeToElement(new { hvac_modes = new[] { "cool", "heat", "fan_only", "off" }, fan_modes = new[] { "自动", "低风", "中风", "高风" }, swing_modes = new[] { "off", "vertical" }, options = new[] { "智能适配", "轻柔", "全身" }, current_temperature = 24.9, temperature = 23, temperature_unit = "°C", min_temp = 5, max_temp = 35, target_temp_step = 0.5, supported_features = 393, fan_mode = "低风" }) }; private static string SerializeApi(object value) => JsonConvert.SerializeObject(value, new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver(), DateFormatString = "yyyy-MM-dd HH:mm:ss" }); [Theory] [InlineData("hvac_modes")] [InlineData("fan_modes")] [InlineData("swing_modes")] [InlineData("options")] public void ApiSerializesChoiceArraysAsStringsNotJsonElementMetadata(string key) { var state = State(); var safe = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state)!; var json = JObject.Parse(SerializeApi(safe)); var values = Assert.IsType(json[key]); Assert.All(values, value => Assert.Equal(JTokenType.String, value.Type)); Assert.Equal(state.Attributes.GetProperty(key).EnumerateArray().Select(value => value.GetString()), values.Select(value => value.Value())); } [Theory] [InlineData(false)] [InlineData(true)] public void DiscoveredClimateAndParameterReadingsKeepCapabilitiesAcrossApiSerialization(bool useSystemJson) { var state = State(); var states = new Dictionary { [ClimateId] = state }; var options = Options.Create(new HomeAssistantOptions { Entities = new() { Climate = ClimateId } }); var module = new ClimateModule(new DeviceActionRunner(new NoWritesGateway(), TimeProvider.System, new()), options); var catalog = new HomeAssistantDeviceCatalog(options, new DeviceModuleRegistry([module])); var devices = catalog.BuildDiscoveredDevices(new HomeAssistantRegistries { Devices = [new() { Id = "ac", Name = "空调" }], Entities = [new() { EntityId = ClimateId, DeviceId = "ac" }] }, states); var status = new HomeAssistantStatusOutput { Connected = true, Devices = devices, ControlParameters = module.Parameters.ToDictionary(parameter => parameter.Key, parameter => new HomeAssistantParameterStateOutput { EntityId = parameter.EntityId, State = state.State, Available = true, Attributes = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state) }) }; var wire = JObject.Parse(useSystemJson ? SystemJson.Serialize(status, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }) : SerializeApi(status)); var device = wire["devices"]![0]!; Assert.Equal("climate", device["moduleKey"]!.Value()); Assert.True(device["online"]!.Value()); Assert.Equal(ClimateId, device["primaryControlEntityId"]!.Value()); var entity = device["entities"]![0]!; Assert.True(entity["available"]!.Value()); Assert.Equal("off", entity["state"]!.Value()); // 关闭不等于不可用。 foreach (var reading in new[] { entity, wire["controlParameters"]!["climate_mode"]!, wire["controlParameters"]!["climate_temperature"]!, wire["controlParameters"]!["climate_fan_mode"]! }) { var attributes = reading["attributes"]!; Assert.Equal(new[] { "cool", "heat", "fan_only", "off" }, attributes["hvac_modes"]!.Values()); Assert.Equal(new[] { "自动", "低风", "中风", "高风" }, attributes["fan_modes"]!.Values()); Assert.Equal(24.9, attributes["current_temperature"]!.Value()); Assert.Equal(23, attributes["temperature"]!.Value()); Assert.Equal("°C", attributes["temperature_unit"]!.Value()); Assert.Equal(0.5, attributes["target_temp_step"]!.Value()); Assert.Equal(393, attributes["supported_features"]!.Value()); } } [Theory] [InlineData(false)] [InlineData(true)] public void ConversionPreservesJsonTypesAndDoesNotExposeUnapprovedRootAttributes(bool useSystemJson) { using var document = JsonDocument.Parse(""" {"options":["off",null,true,12,0.5,["nested"],{"choice":"cool","flag":false}], "temperature":null,"is_volume_muted":false,"volume_level":0.5, "access_token":"private","unapproved_attribute":{"secret":"private"}} """); var state = new HomeAssistantState { Attributes = document.RootElement }; var safe = HomeAssistantDeviceCatalog.ExtractSafeAttributes(state)!; var wire = JObject.Parse(useSystemJson ? SystemJson.Serialize(safe) : SerializeApi(safe)); var expected = JObject.Parse(""" {"options":["off",null,true,12,0.5,["nested"],{"choice":"cool","flag":false}], "temperature":null,"is_volume_muted":false,"volume_level":0.5} """); Assert.True(JToken.DeepEquals(expected, wire), wire.ToString()); } private sealed class NoWritesGateway : IHomeAssistantGateway { public Task> GetStatesAsync(CancellationToken cancellationToken = default) => throw new InvalidOperationException("Serialization tests must not read a device"); public Task CallServiceAsync(string domain, string service, IReadOnlyDictionary data, CancellationToken cancellationToken = default) => throw new InvalidOperationException("Serialization tests must not control a device"); } }