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");
}
}