diff --git a/docs/api.md b/docs/api.md
index 03ae696c24..c21557c392 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -3673,6 +3673,7 @@ This module provides queries for ZCL loading
* [~commandMap(clusterId, packageId, commands)](#module_DB API_ zcl loading queries..commandMap) ⇒
* [~fieldMap(eventId, packageId, fields)](#module_DB API_ zcl loading queries..fieldMap) ⇒
* [~argMap(cmdId, packageId, args)](#module_DB API_ zcl loading queries..argMap) ⇒
+ * [~filterDuplicates(db, packageId, data, keys, elementName)](#module_DB API_ zcl loading queries..filterDuplicates) ⇒ Array
* [~insertAttributeAccessData(db, packageId, accessData)](#module_DB API_ zcl loading queries..insertAttributeAccessData) ⇒
* [~insertCommandAccessData(db, packageId, accessData)](#module_DB API_ zcl loading queries..insertCommandAccessData) ⇒
* [~insertEventAccessData(db, packageId, accessData)](#module_DB API_ zcl loading queries..insertEventAccessData) ⇒
@@ -3785,6 +3786,24 @@ Transforms the array of command args in a certain format and returns it.
| packageId | \*
|
| args | \*
|
+
+
+### DB API: zcl loading queries~filterDuplicates(db, packageId, data, keys, elementName) ⇒ Array
+Filters out duplicates in an array of objects based on specified keys and logs a warning for each duplicate found.
+This function is used to filter out duplicates in command, attribute, and event data before inserting into the database.
+Treats `null` and `0` as equivalent.
+
+**Kind**: inner method of [DB API: zcl loading queries
](#module_DB API_ zcl loading queries)
+**Returns**: Array
- - Array of unique objects (duplicates removed).
+
+| Param | Type | Description |
+| --- | --- | --- |
+| db | \*
| |
+| packageId | \*
| |
+| data | Array
| Array of objects. |
+| keys | Array
| Array of keys to compare for duplicates (e.g., ['code', 'manufacturerCode']). |
+| elementName | \*
| |
+
### DB API: zcl loading queries~insertAttributeAccessData(db, packageId, accessData) ⇒
diff --git a/src-electron/db/query-loader.js b/src-electron/db/query-loader.js
index 680ce6c04b..cbdf24b220 100644
--- a/src-electron/db/query-loader.js
+++ b/src-electron/db/query-loader.js
@@ -138,7 +138,7 @@ INSERT INTO COMMAND_ARG (
// Attribute table needs to be unique based on:
// UNIQUE("CLUSTER_REF", "PACKAGE_REF", "CODE", "MANUFACTURER_CODE")
const INSERT_ATTRIBUTE_QUERY = `
-INSERT OR REPLACE INTO ATTRIBUTE (
+INSERT INTO ATTRIBUTE (
CLUSTER_REF,
PACKAGE_REF,
CODE,
@@ -360,6 +360,50 @@ function argMap(cmdId, packageId, args) {
packageId
])
}
+/**
+ * Filters out duplicates in an array of objects based on specified keys and logs a warning for each duplicate found.
+ * This function is used to filter out duplicates in command, attribute, and event data before inserting into the database.
+ * Treats `null` and `0` as equivalent.
+ *
+ * @param {*} db
+ * @param {*} packageId
+ * @param {Array} data - Array of objects.
+ * @param {Array} keys - Array of keys to compare for duplicates (e.g., ['code', 'manufacturerCode']).
+ * @param {*} elementName
+ * @returns {Array} - Array of unique objects (duplicates removed).
+ */
+function filterDuplicates(db, packageId, data, keys, elementName) {
+ let seen = new Map()
+ let uniqueItems = []
+
+ data.forEach((item, index) => {
+ let anyKeysPresent = keys.some((key) => key in item)
+
+ if (!anyKeysPresent) {
+ // If all keys are missing, treat this item as unique
+ uniqueItems.push(item)
+ } else {
+ let uniqueKey = keys
+ .map((key) => (item[key] === null || item[key] === 0 ? 0 : item[key]))
+ .join('|')
+
+ if (seen.has(uniqueKey)) {
+ // Log a warning with the duplicate information
+ queryNotification.setNotification(
+ db,
+ 'ERROR',
+ `Duplicate ${elementName} found: ${JSON.stringify(item)}`,
+ packageId
+ )
+ } else {
+ seen.set(uniqueKey, true)
+ uniqueItems.push(item)
+ }
+ }
+ })
+
+ return uniqueItems
+}
/**
* access data is array of objects, containing id/op/role/modifier.
@@ -655,7 +699,7 @@ async function insertClusterExtensions(db, packageId, knownPackages, data) {
)}) AND CODE = ?`,
data.map((cluster) => [cluster.code])
)
- .then((rows) => {
+ .then(async (rows) => {
let commands = {
data: [],
args: [],
@@ -678,17 +722,38 @@ async function insertClusterExtensions(db, packageId, knownPackages, data) {
// NOTE: This code must stay in sync with insertClusters
if ('commands' in data[i]) {
let cmds = data[i].commands
+ cmds = filterDuplicates(
+ db,
+ packageId,
+ cmds,
+ ['code', 'manufacturerCode', 'source'],
+ 'command'
+ ) // Removes any duplicates based of db unique constraint and logs package notification (avoids SQL error)
commands.data.push(...commandMap(lastId, packageId, cmds))
commands.args.push(...cmds.map((command) => command.args))
commands.access.push(...cmds.map((command) => command.access))
}
if ('attributes' in data[i]) {
let atts = data[i].attributes
+ atts = filterDuplicates(
+ db,
+ packageId,
+ atts,
+ ['code', 'manufacturerCode', 'side'],
+ 'attribute'
+ ) // Removes any duplicates based of db unique constraint and logs package notification (avoids SQL error)
attributes.data.push(...attributeMap(lastId, packageId, atts))
attributes.access.push(...atts.map((at) => at.access))
}
if ('events' in data[i]) {
let evs = data[i].events
+ evs = filterDuplicates(
+ db,
+ packageId,
+ evs,
+ ['code', 'manufacturerCode'],
+ 'event'
+ ) // Removes any duplicates based of db unique constraint and logs package notification (avoids SQL error)
events.data.push(...eventMap(lastId, packageId, evs))
events.fields.push(...evs.map((event) => event.fields))
events.access.push(...evs.map((event) => event.access))
@@ -720,7 +785,15 @@ async function insertClusterExtensions(db, packageId, knownPackages, data) {
let pCommand = insertCommands(db, packageId, commands)
let pAttribute = insertAttributes(db, packageId, attributes)
let pEvent = insertEvents(db, packageId, events)
- return Promise.all([pCommand, pAttribute, pEvent])
+ return Promise.all([pCommand, pAttribute, pEvent]).catch((err) => {
+ if (err.includes('SQLITE_CONSTRAINT') && err.includes('UNIQUE')) {
+ env.logDebug(
+ `CRC match for file with package id ${packageId}, skipping parsing.`
+ )
+ } else {
+ throw err
+ }
+ })
})
}
@@ -783,17 +856,38 @@ async function insertClusters(db, packageId, data) {
// NOTE: This code must stay in sync with insertClusterExtensions
if ('commands' in data[i]) {
let cmds = data[i].commands
+ cmds = filterDuplicates(
+ db,
+ packageId,
+ cmds,
+ ['code', 'manufacturerCode', 'source'],
+ 'command'
+ ) // Removes any duplicates based of db unique constraint and logs package notification (avoids SQL error)
commands.data.push(...commandMap(lastId, packageId, cmds))
commands.args.push(...cmds.map((command) => command.args))
commands.access.push(...cmds.map((command) => command.access))
}
if ('attributes' in data[i]) {
let atts = data[i].attributes
+ atts = filterDuplicates(
+ db,
+ packageId,
+ atts,
+ ['code', 'manufacturerCode', 'side'],
+ 'attribute'
+ ) // Removes any duplicates based of db unique constraint and logs package notification (avoids SQL error)
attributes.data.push(...attributeMap(lastId, packageId, atts))
attributes.access.push(...atts.map((at) => at.access))
}
if ('events' in data[i]) {
let evs = data[i].events
+ evs = filterDuplicates(
+ db,
+ packageId,
+ evs,
+ ['code', 'manufacturerCode'],
+ 'event'
+ ) // Removes any duplicates based of db unique constraint and logs package notification (avoids SQL error)
events.data.push(...eventMap(lastId, packageId, evs))
events.fields.push(...evs.map((event) => event.fields))
events.access.push(...evs.map((event) => event.access))
diff --git a/src-electron/db/zap-schema.sql b/src-electron/db/zap-schema.sql
index 797ecf02bb..b1fb5e8352 100644
--- a/src-electron/db/zap-schema.sql
+++ b/src-electron/db/zap-schema.sql
@@ -156,10 +156,11 @@ CREATE TABLE IF NOT EXISTS "CLUSTER" (
"INTRODUCED_IN_REF" integer,
"REMOVED_IN_REF" integer,
"API_MATURITY" text,
+ "MANUFACTURER_CODE_DERIVED" AS (COALESCE(MANUFACTURER_CODE, 0)),
foreign key (INTRODUCED_IN_REF) references SPEC(SPEC_ID) ON DELETE CASCADE ON UPDATE CASCADE,
foreign key (REMOVED_IN_REF) references SPEC(SPEC_ID) ON DELETE CASCADE ON UPDATE CASCADE,
foreign key (PACKAGE_REF) references PACKAGE(PACKAGE_ID) ON DELETE CASCADE ON UPDATE CASCADE
- UNIQUE(PACKAGE_REF, CODE, MANUFACTURER_CODE)
+ UNIQUE(PACKAGE_REF, CODE, MANUFACTURER_CODE_DERIVED)
);
/*
COMMAND table contains commands contained inside a cluster.
@@ -183,12 +184,13 @@ CREATE TABLE IF NOT EXISTS "COMMAND" (
"RESPONSE_REF" integer,
"IS_DEFAULT_RESPONSE_ENABLED" integer,
"IS_LARGE_MESSAGE" integer,
+ "MANUFACTURER_CODE_DERIVED" AS (COALESCE(MANUFACTURER_CODE, 0)),
foreign key (INTRODUCED_IN_REF) references SPEC(SPEC_ID) ON DELETE CASCADE ON UPDATE CASCADE,
foreign key (REMOVED_IN_REF) references SPEC(SPEC_ID) ON DELETE CASCADE ON UPDATE CASCADE,
foreign key (CLUSTER_REF) references CLUSTER(CLUSTER_ID) ON DELETE CASCADE ON UPDATE CASCADE,
foreign key (PACKAGE_REF) references PACKAGE(PACKAGE_ID) ON DELETE CASCADE ON UPDATE CASCADE,
foreign key (RESPONSE_REF) references COMMAND(COMMAND_ID) ON DELETE CASCADE ON UPDATE CASCADE
- UNIQUE(CLUSTER_REF, PACKAGE_REF, CODE, MANUFACTURER_CODE, SOURCE)
+ UNIQUE(CLUSTER_REF, PACKAGE_REF, CODE, MANUFACTURER_CODE_DERIVED, SOURCE)
);
/*
COMMAND_ARG table contains arguments for a command.
@@ -233,11 +235,12 @@ CREATE TABLE IF NOT EXISTS "EVENT" (
"PRIORITY" text,
"INTRODUCED_IN_REF" integer,
"REMOVED_IN_REF" integer,
+ "MANUFACTURER_CODE_DERIVED" AS (COALESCE(MANUFACTURER_CODE, 0)),
foreign key (CLUSTER_REF) references CLUSTER(CLUSTER_ID) ON DELETE CASCADE ON UPDATE CASCADE,
foreign key (PACKAGE_REF) references PACKAGE(PACKAGE_ID) ON DELETE CASCADE ON UPDATE CASCADE,
foreign key (INTRODUCED_IN_REF) references SPEC(SPEC_ID) ON DELETE CASCADE ON UPDATE CASCADE,
foreign key (REMOVED_IN_REF) references SPEC(SPEC_ID) ON DELETE CASCADE ON UPDATE CASCADE
- UNIQUE(CLUSTER_REF, PACKAGE_REF, CODE, MANUFACTURER_CODE)
+ UNIQUE(CLUSTER_REF, PACKAGE_REF, CODE, MANUFACTURER_CODE_DERIVED)
);
/*
EVENT_FIELD table contains events for a given cluster.
@@ -294,11 +297,12 @@ CREATE TABLE IF NOT EXISTS "ATTRIBUTE" (
"API_MATURITY" text,
"IS_CHANGE_OMITTED" integer,
"PERSISTENCE" text,
+ "MANUFACTURER_CODE_DERIVED" AS (COALESCE(MANUFACTURER_CODE, 0)),
foreign key (INTRODUCED_IN_REF) references SPEC(SPEC_ID) ON DELETE CASCADE ON UPDATE CASCADE,
foreign key (REMOVED_IN_REF) references SPEC(SPEC_ID) ON DELETE CASCADE ON UPDATE CASCADE,
foreign key (CLUSTER_REF) references CLUSTER(CLUSTER_ID) ON DELETE CASCADE ON UPDATE CASCADE,
foreign key (PACKAGE_REF) references PACKAGE(PACKAGE_ID) ON DELETE CASCADE ON UPDATE CASCADE
- UNIQUE("CLUSTER_REF", "PACKAGE_REF", "CODE", "MANUFACTURER_CODE")
+ UNIQUE("CLUSTER_REF", "PACKAGE_REF", "CODE", "MANUFACTURER_CODE_DERIVED", "SIDE")
);
/*
diff --git a/test/custom-matter-xml.test.js b/test/custom-matter-xml.test.js
index c8e540bcce..69ba1d3955 100644
--- a/test/custom-matter-xml.test.js
+++ b/test/custom-matter-xml.test.js
@@ -448,7 +448,13 @@ test(
)
expect(
packageNotif.some((notif) => notif.message.includes('type contradiction'))
- ).toBeTruthy() // checks if the correct warning is thrown
+ ).toBeTruthy() // checks if the correct type contradiction warning is thrown
+
+ expect(
+ packageNotif.some((notif) =>
+ notif.message.includes('Duplicate attribute found')
+ )
+ ).toBeTruthy() // checks if the correct duplicate attribute error is thrown
let sessionNotif = await querySessionNotification.getNotification(db, sid)
expect(
diff --git a/test/resource/custom-cluster/matter-bad-custom.xml b/test/resource/custom-cluster/matter-bad-custom.xml
index 9c05d06d58..dda988cf8b 100644
--- a/test/resource/custom-cluster/matter-bad-custom.xml
+++ b/test/resource/custom-cluster/matter-bad-custom.xml
@@ -1,3 +1,4 @@
+
@@ -11,6 +12,7 @@
// intentional undefined type errors
Sample Mfg Specific Attribute 6
+ Sample Mfg Specific Attribute 6 Duplicate
Sample Mfg Specific Attribute 8
Client command that turns the device on with a transition given
diff --git a/test/test-util.js b/test/test-util.js
index d6157c3deb..7aff4c5e92 100644
--- a/test/test-util.js
+++ b/test/test-util.js
@@ -170,12 +170,12 @@ exports.testMatterCustomZap =
exports.totalClusterCount = 111
exports.totalDomainCount = 20
-exports.totalCommandArgsCount = 1786
-exports.totalCommandCount = 632
+exports.totalCommandArgsCount = 1785
+exports.totalCommandCount = 631
exports.totalEventFieldCount = 3
exports.totalEventCount = 1
exports.totalAttributeCount = 3438
-exports.totalClusterCommandCount = 609
+exports.totalClusterCommandCount = 608
exports.totalServerAttributeCount = 2962
exports.totalSpecCount = 24
exports.totalEnumCount = 211
diff --git a/zcl-builtin/silabs/demo.xml b/zcl-builtin/silabs/demo.xml
index a983b5fe55..272661e718 100644
--- a/zcl-builtin/silabs/demo.xml
+++ b/zcl-builtin/silabs/demo.xml
@@ -15,10 +15,10 @@
Send a hello command to the server
-
+
Example test event