blob: 3e125bb4b7a830cf86c55c1ca7b95136a93797ea [file]
/*
*
* Copyright (c) 2026 Project CHIP Authors
* All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include <app/clusters/zone-management-server/ZoneManagementCluster.h>
#include <app/persistence/AttributePersistence.h>
#include <app/server-cluster/testing/ClusterTester.h>
#include <app/server-cluster/testing/TestServerClusterContext.h>
#include <app/server-cluster/testing/ValidateGlobalAttributes.h>
#include <clusters/ZoneManagement/Events.h>
#include <clusters/ZoneManagement/Metadata.h>
#include <pw_unit_test/framework.h>
using namespace chip;
using namespace chip::app;
using namespace chip::app::Clusters;
using namespace chip::app::Clusters::ZoneManagement;
using chip::Testing::ClusterTester;
using chip::Testing::EqualAcceptedCommandSets;
using chip::Testing::EqualGeneratedCommandSets;
using chip::Testing::IsAcceptedCommandsListEqualTo;
using chip::Testing::IsAttributesListEqualTo;
using chip::Testing::IsGeneratedCommandsListEqualTo;
namespace {
constexpr EndpointId kTestEndpointId = 1;
constexpr uint8_t kMaxUserDefinedZones = 5;
constexpr uint8_t kMaxZones = 8;
constexpr uint8_t kSensitivityMax = 4;
const TwoDCartesianVertexStruct kTwoDMaxPoint{ 640, 480 };
class MockDelegate : public Delegate
{
public:
Protocols::InteractionModel::Status CreateTwoDCartesianZone(const TwoDCartesianZoneStorage & zone,
uint16_t & outZoneID) override
{
outZoneID = mNextZoneId++;
ZoneInformationStorage zoneInfo;
zoneInfo.Set(outZoneID, ZoneTypeEnum::kTwoDCARTZone, ZoneSourceEnum::kUser, MakeOptional(zone));
mPersistedZones.push_back(zoneInfo);
mCreateZoneCalls++;
return Protocols::InteractionModel::Status::Success;
}
Protocols::InteractionModel::Status UpdateTwoDCartesianZone(uint16_t zoneId, const TwoDCartesianZoneStorage & zone) override
{
for (auto & existing : mPersistedZones)
{
if (existing.zoneID == zoneId)
{
existing.Set(zoneId, ZoneTypeEnum::kTwoDCARTZone, ZoneSourceEnum::kUser, MakeOptional(zone));
mUpdateZoneCalls++;
return Protocols::InteractionModel::Status::Success;
}
}
return Protocols::InteractionModel::Status::NotFound;
}
Protocols::InteractionModel::Status RemoveZone(uint16_t zoneId) override
{
const size_t originalSize = mPersistedZones.size();
mPersistedZones.erase(std::remove_if(mPersistedZones.begin(), mPersistedZones.end(),
[zoneId](const ZoneInformationStorage & zone) { return zone.zoneID == zoneId; }),
mPersistedZones.end());
if (mPersistedZones.size() == originalSize)
{
return Protocols::InteractionModel::Status::NotFound;
}
mRemoveZoneCalls++;
return Protocols::InteractionModel::Status::Success;
}
Protocols::InteractionModel::Status CreateTrigger(const ZoneTriggerControlStruct & trigger) override
{
mPersistedTriggers.push_back(trigger);
return Protocols::InteractionModel::Status::Success;
}
Protocols::InteractionModel::Status UpdateTrigger(const ZoneTriggerControlStruct & trigger) override
{
for (auto & existing : mPersistedTriggers)
{
if (existing.zoneID == trigger.zoneID)
{
existing = trigger;
return Protocols::InteractionModel::Status::Success;
}
}
return Protocols::InteractionModel::Status::NotFound;
}
Protocols::InteractionModel::Status RemoveTrigger(uint16_t zoneId) override
{
mPersistedTriggers.erase(
std::remove_if(mPersistedTriggers.begin(), mPersistedTriggers.end(),
[zoneId](const ZoneTriggerControlStruct & trigger) { return trigger.zoneID == zoneId; }),
mPersistedTriggers.end());
return Protocols::InteractionModel::Status::Success;
}
void OnAttributeChanged(AttributeId attributeId) override { mChangedAttributes.push_back(attributeId); }
CHIP_ERROR PersistentAttributesLoadedCallback() override
{
mPersistentAttributesLoadedCalls++;
return CHIP_NO_ERROR;
}
CHIP_ERROR LoadZones(std::vector<ZoneInformationStorage> & zones) override
{
zones = mPersistedZones;
return CHIP_NO_ERROR;
}
CHIP_ERROR LoadTriggers(std::vector<ZoneTriggerControlStruct> & triggers) override
{
triggers = mPersistedTriggers;
return CHIP_NO_ERROR;
}
std::vector<ZoneInformationStorage> mPersistedZones;
std::vector<ZoneTriggerControlStruct> mPersistedTriggers;
std::vector<AttributeId> mChangedAttributes;
uint16_t mNextZoneId = 1;
unsigned mCreateZoneCalls = 0;
unsigned mUpdateZoneCalls = 0;
unsigned mRemoveZoneCalls = 0;
unsigned mPersistentAttributesLoadedCalls = 0;
};
class TestZoneManagementCluster : public ::testing::Test
{
public:
static void SetUpTestSuite() { ASSERT_EQ(Platform::MemoryInit(), CHIP_NO_ERROR); }
static void TearDownTestSuite() { Platform::MemoryShutdown(); }
protected:
ZoneManagementCluster CreateCluster(BitFlags<Feature> features)
{
return CreateCluster(features, kMaxUserDefinedZones, kMaxZones, kSensitivityMax, kTwoDMaxPoint);
}
ZoneManagementCluster CreateCluster(BitFlags<Feature> features, uint8_t maxUserDefinedZones, uint8_t maxZones,
uint8_t sensitivityMax, const TwoDCartesianVertexStruct & twoDCartesianMax)
{
return ZoneManagementCluster(ZoneManagementCluster::Context{
.delegate = mDelegate,
.endpointId = kTestEndpointId,
.features = features,
.config = {
.maxUserDefinedZones = maxUserDefinedZones,
.maxZones = maxZones,
.sensitivityMax = sensitivityMax,
.twoDCartesianMax = twoDCartesianMax,
},
});
}
static ConcreteAttributePath SensitivityPath()
{
return ConcreteAttributePath(kTestEndpointId, ZoneManagement::Id, Attributes::Sensitivity::Id);
}
static std::array<TwoDCartesianVertexStruct, 3> MakeTriangle(uint16_t offset)
{
return { TwoDCartesianVertexStruct{ static_cast<uint16_t>(10 + offset), static_cast<uint16_t>(20 + offset) },
TwoDCartesianVertexStruct{ static_cast<uint16_t>(30 + offset), static_cast<uint16_t>(20 + offset) },
TwoDCartesianVertexStruct{ static_cast<uint16_t>(20 + offset), static_cast<uint16_t>(40 + offset) } };
}
static Commands::CreateTwoDCartesianZone::Type MakeCreateZoneRequest(const char * name,
const std::array<TwoDCartesianVertexStruct, 3> & vertices)
{
Commands::CreateTwoDCartesianZone::Type request;
request.zone.name = CharSpan::fromCharString(name);
request.zone.use = ZoneUseEnum::kMotion;
request.zone.vertices = DataModel::List<const TwoDCartesianVertexStruct>(vertices.data(), vertices.size());
request.zone.color = NullOptional;
return request;
}
MockDelegate mDelegate;
};
TEST_F(TestZoneManagementCluster, AcceptedCommandsAppendElementsGrowsBeyondInitialCapacity)
{
ZoneManagementCluster cluster = CreateCluster(BitFlags<Feature>(Feature::kUserDefined, Feature::kTwoDimensionalCartesianZone));
ConcreteClusterPath path(kTestEndpointId, ZoneManagement::Id);
ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> builder;
ASSERT_EQ(builder.EnsureAppendCapacity(1), CHIP_NO_ERROR);
ASSERT_EQ(cluster.AcceptedCommands(path, builder), CHIP_NO_ERROR);
static constexpr DataModel::AcceptedCommandEntry kExpected[] = {
Commands::CreateTwoDCartesianZone::kMetadataEntry,
Commands::UpdateTwoDCartesianZone::kMetadataEntry,
Commands::RemoveZone::kMetadataEntry,
Commands::CreateOrUpdateTrigger::kMetadataEntry,
Commands::RemoveTrigger::kMetadataEntry,
};
ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> expectedBuilder;
ASSERT_EQ(expectedBuilder.ReferenceExisting(kExpected), CHIP_NO_ERROR);
ASSERT_TRUE(EqualAcceptedCommandSets(builder.TakeBuffer(), expectedBuilder.TakeBuffer()));
ASSERT_TRUE(IsAcceptedCommandsListEqualTo(cluster, { kExpected[0], kExpected[1], kExpected[2], kExpected[3], kExpected[4] }));
}
TEST_F(TestZoneManagementCluster, AcceptedCommandsFollowFeatureGates)
{
ZoneManagementCluster userDefinedOnly = CreateCluster(BitFlags<Feature>(Feature::kUserDefined));
ASSERT_TRUE(IsAcceptedCommandsListEqualTo(userDefinedOnly,
{
Commands::RemoveZone::kMetadataEntry,
Commands::CreateOrUpdateTrigger::kMetadataEntry,
Commands::RemoveTrigger::kMetadataEntry,
}));
ZoneManagementCluster noOptionalFeatures = CreateCluster(BitFlags<Feature>());
ASSERT_TRUE(IsAcceptedCommandsListEqualTo(noOptionalFeatures,
{
Commands::CreateOrUpdateTrigger::kMetadataEntry,
Commands::RemoveTrigger::kMetadataEntry,
}));
}
TEST_F(TestZoneManagementCluster, AttributeListFollowsFeatureGates)
{
ZoneManagementCluster cluster = CreateCluster(BitFlags<Feature>(Feature::kUserDefined, Feature::kTwoDimensionalCartesianZone));
ASSERT_TRUE(IsAttributesListEqualTo(cluster,
{
Attributes::MaxUserDefinedZones::kMetadataEntry,
Attributes::MaxZones::kMetadataEntry,
Attributes::Zones::kMetadataEntry,
Attributes::Triggers::kMetadataEntry,
Attributes::SensitivityMax::kMetadataEntry,
Attributes::Sensitivity::kMetadataEntry,
Attributes::TwoDCartesianMax::kMetadataEntry,
}));
}
TEST_F(TestZoneManagementCluster, FeatureMapAttributeEncodesConfiguredFeatures)
{
const BitFlags<Feature> features(Feature::kTwoDimensionalCartesianZone, Feature::kUserDefined, Feature::kFocusZones);
ZoneManagementCluster cluster = CreateCluster(features);
ClusterTester tester(cluster);
uint32_t featureMap = 0;
ASSERT_EQ(tester.ReadAttribute(Globals::Attributes::FeatureMap::Id, featureMap), Protocols::InteractionModel::Status::Success);
ASSERT_EQ(featureMap, features.Raw());
}
TEST_F(TestZoneManagementCluster, SensitivityWritePersistsAndReloadsOnStartup)
{
auto cluster = CreateCluster(BitFlags<Feature>());
Testing::TestServerClusterContext context;
ASSERT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
ASSERT_EQ(tester.WriteAttribute(Attributes::Sensitivity::Id, static_cast<uint8_t>(3)),
Protocols::InteractionModel::Status::Success);
ASSERT_EQ(cluster.GetSensitivity(), 3);
uint8_t persistedSensitivity = 0;
AttributePersistence persistence(context.Get().attributeStorage);
ASSERT_TRUE(persistence.LoadNativeEndianValue(SensitivityPath(), persistedSensitivity, static_cast<uint8_t>(0)));
ASSERT_EQ(persistedSensitivity, 3);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
auto reloadedCluster = CreateCluster(BitFlags<Feature>());
ASSERT_EQ(reloadedCluster.Startup(context.Get()), CHIP_NO_ERROR);
ASSERT_EQ(reloadedCluster.GetSensitivity(), 3);
}
TEST_F(TestZoneManagementCluster, GeneratedCommandsAppendElementsGrowsBeyondInitialCapacity)
{
ZoneManagementCluster cluster = CreateCluster(BitFlags<Feature>(Feature::kUserDefined, Feature::kTwoDimensionalCartesianZone));
ConcreteClusterPath path(kTestEndpointId, ZoneManagement::Id);
ReadOnlyBufferBuilder<CommandId> builder;
ASSERT_EQ(builder.EnsureAppendCapacity(0), CHIP_NO_ERROR);
ASSERT_EQ(cluster.GeneratedCommands(path, builder), CHIP_NO_ERROR);
static constexpr CommandId kExpected[] = {
Commands::CreateTwoDCartesianZoneResponse::Id,
};
ReadOnlyBufferBuilder<CommandId> expectedBuilder;
ASSERT_EQ(expectedBuilder.ReferenceExisting(kExpected), CHIP_NO_ERROR);
ASSERT_TRUE(EqualGeneratedCommandSets(builder.TakeBuffer(), expectedBuilder.TakeBuffer()));
ASSERT_TRUE(IsGeneratedCommandsListEqualTo(cluster, { kExpected[0] }));
}
TEST_F(TestZoneManagementCluster, GeneratedCommandsEmptyWhenCreateIsNotSupported)
{
ZoneManagementCluster userDefinedOnly = CreateCluster(BitFlags<Feature>(Feature::kUserDefined));
ASSERT_TRUE(IsGeneratedCommandsListEqualTo(userDefinedOnly, {}));
ZoneManagementCluster twoDOnly = CreateCluster(BitFlags<Feature>(Feature::kTwoDimensionalCartesianZone));
ASSERT_TRUE(IsGeneratedCommandsListEqualTo(twoDOnly, {}));
}
TEST_F(TestZoneManagementCluster, StartupLoadsPersistedZonesAndTriggersFromDelegate)
{
const auto vertices = MakeTriangle(0);
TwoDCartesianZoneStorage zone;
zone.Set("Entry"_span, ZoneUseEnum::kMotion, std::vector<TwoDCartesianVertexStruct>(vertices.begin(), vertices.end()),
NullOptional);
ZoneInformationStorage zoneInfo;
zoneInfo.Set(55, ZoneTypeEnum::kTwoDCARTZone, ZoneSourceEnum::kMfg, MakeOptional(zone));
mDelegate.mPersistedZones.push_back(zoneInfo);
ZoneTriggerControlStruct trigger;
trigger.zoneID = 55;
trigger.initialDuration = 10;
trigger.augmentationDuration = 5;
trigger.maxDuration = 10;
trigger.blindDuration = 0;
trigger.sensitivity = NullOptional;
mDelegate.mPersistedTriggers.push_back(trigger);
auto cluster = CreateCluster(BitFlags<Feature>());
Testing::TestServerClusterContext context;
ASSERT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ASSERT_EQ(mDelegate.mPersistentAttributesLoadedCalls, 1u);
ASSERT_EQ(cluster.GetZones().size(), 1u);
ASSERT_EQ(cluster.GetTriggers().size(), 1u);
ASSERT_TRUE(cluster.GetTriggerForZone(55).HasValue());
}
TEST_F(TestZoneManagementCluster, InvokeCreateUpdateAndRemoveZoneCommands)
{
auto cluster = CreateCluster(BitFlags<Feature>(Feature::kUserDefined, Feature::kTwoDimensionalCartesianZone));
ClusterTester tester(cluster);
ASSERT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
const auto createVertices = MakeTriangle(0);
auto createResult = tester.Invoke(MakeCreateZoneRequest("Zone One", createVertices));
ASSERT_TRUE(createResult.IsSuccess());
if (!createResult.response.has_value())
{
FAIL() << "Expected createResult.response to have a value";
return;
}
ASSERT_EQ(createResult.response->zoneID, 1);
ASSERT_EQ(mDelegate.mCreateZoneCalls, 1u);
ASSERT_EQ(cluster.GetZones().size(), 1u);
ASSERT_TRUE(cluster.GetZones().front().twoDCartZoneStorage.HasValue());
ASSERT_TRUE(cluster.GetZones().front().twoDCartZoneStorage.Value().name.data_equal("Zone One"_span));
const auto updateVertices = MakeTriangle(100);
Commands::UpdateTwoDCartesianZone::Type updateRequest;
updateRequest.zoneID = 1;
updateRequest.zone.name = "Zone Two"_span;
updateRequest.zone.use = ZoneUseEnum::kMotion;
updateRequest.zone.vertices = DataModel::List<const TwoDCartesianVertexStruct>(updateVertices.data(), updateVertices.size());
updateRequest.zone.color = NullOptional;
auto updateResult = tester.Invoke(updateRequest);
ASSERT_TRUE(updateResult.IsSuccess());
ASSERT_EQ(mDelegate.mUpdateZoneCalls, 1u);
ASSERT_EQ(cluster.GetZones().size(), 1u);
ASSERT_TRUE(cluster.GetZones().front().twoDCartZoneStorage.Value().name.data_equal("Zone Two"_span));
Commands::RemoveZone::Type removeRequest;
removeRequest.zoneID = 1;
auto removeResult = tester.Invoke(removeRequest);
ASSERT_TRUE(removeResult.IsSuccess());
ASSERT_EQ(mDelegate.mRemoveZoneCalls, 1u);
ASSERT_TRUE(cluster.GetZones().empty());
ASSERT_TRUE(mDelegate.mPersistedZones.empty());
}
TEST_F(TestZoneManagementCluster, StartupFailsForInvalidConfiguration)
{
Testing::TestServerClusterContext context;
auto invalidSensitivity = CreateCluster(BitFlags<Feature>(), kMaxUserDefinedZones, kMaxZones, 1, kTwoDMaxPoint);
ASSERT_EQ(invalidSensitivity.Startup(context.Get()), CHIP_ERROR_INVALID_ARGUMENT);
auto invalidUserDefined = CreateCluster(BitFlags<Feature>(Feature::kUserDefined), 4, kMaxZones, kSensitivityMax, kTwoDMaxPoint);
ASSERT_EQ(invalidUserDefined.Startup(context.Get()), CHIP_ERROR_INVALID_ARGUMENT);
}
TEST_F(TestZoneManagementCluster, EventGenerationProducesTriggeredAndStoppedEvents)
{
auto cluster = CreateCluster(BitFlags<Feature>());
ClusterTester tester(cluster);
ASSERT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
ASSERT_EQ(cluster.GenerateZoneTriggeredEvent(7, ZoneEventTriggeredReasonEnum::kMotion),
Protocols::InteractionModel::Status::Success);
auto triggeredEvent = tester.GetNextGeneratedEvent();
if (!triggeredEvent.has_value())
{
FAIL() << "Expected triggeredEvent to have a value";
return;
}
Events::ZoneTriggered::DecodableType triggeredData;
ASSERT_EQ(triggeredEvent->GetEventData(triggeredData), CHIP_NO_ERROR);
ASSERT_EQ(triggeredData.zone, 7);
ASSERT_EQ(triggeredData.reason, ZoneEventTriggeredReasonEnum::kMotion);
ASSERT_EQ(cluster.GenerateZoneStoppedEvent(7, ZoneEventStoppedReasonEnum::kActionStopped),
Protocols::InteractionModel::Status::Success);
auto stoppedEvent = tester.GetNextGeneratedEvent();
if (!stoppedEvent.has_value())
{
FAIL() << "Expected stoppedEvent to have a value";
return;
}
Events::ZoneStopped::DecodableType stoppedData;
ASSERT_EQ(stoppedEvent->GetEventData(stoppedData), CHIP_NO_ERROR);
ASSERT_EQ(stoppedData.zone, 7);
ASSERT_EQ(stoppedData.reason, ZoneEventStoppedReasonEnum::kActionStopped);
}
} // namespace