From 0533f78be433f28fe9085d856caf000630691670 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 11:56:15 +0200 Subject: [PATCH] feat(cctv): resolve verbose registry model strings via token fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some catalogs publish a full product title rather than the bare model, e.g. "AXIS Q6155-E PTZ Dome Network Camera" — which the exact model/alias match misses. Add a fallback: after the exact match, scan for a record whose DISTINCTIVE token (a >=4-char model/alias word containing a digit or hyphen, so "dome"/"network"/"ptz"/"hd" never qualify) appears as a whole word in the query. The most specific (longest) matched token wins; if two records tie on the longest matched token the result is ambiguous and returns null, so the lookup never guesses. Exact matches are unchanged and still take precedence. --- scripts/package-boundaries.json | 3 ++ src/layers/cctv/modelSpecs.js | 69 +++++++++++++++++++++++++++-- src/layers/cctv/modelSpecs.test.mjs | 37 ++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..a987623a4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1079,6 +1079,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -1496,6 +1497,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -2136,6 +2138,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 463e750ad..1252ce013 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -297,21 +297,82 @@ const RECORDS = [ }, ]; +/** Lowercase + collapse whitespace, the shared normal form for matching. */ +function normalize(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/\s+/g, ' '); +} + const BY_MODEL = new Map(); for (const record of RECORDS) { - BY_MODEL.set(record.model.toLowerCase(), record); + BY_MODEL.set(normalize(record.model), record); for (const alias of record.aliases || []) - BY_MODEL.set(alias.toLowerCase(), record); + BY_MODEL.set(normalize(alias), record); } /** - * Looks up a spec record by model string (case-insensitive; aliases match too). + * DISTINCTIVE tokens of a record — the whitespace words of its model and + * aliases that identify hardware rather than describe it. A token qualifies + * only when it is >= 4 chars AND contains a digit or hyphen, so generic words + * ("dome", "network", "camera", "series", "ptz") are never distinctive and a + * verbose registry string cannot match on them alone. Surrounding punctuation + * is trimmed so "(NDP-7512-Z30)" indexes as "ndp-7512-z30". + * @param {Object} record + * @returns {string[]} Unique distinctive tokens, lowercased. + */ +function distinctiveTokens(record) { + const out = new Set(); + for (const name of [record.model, ...(record.aliases || [])]) { + for (const raw of normalize(name).split(' ')) { + const token = raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''); + if (token.length >= 4 && /[0-9-]/.test(token)) out.add(token); + } + } + return [...out]; +} + +/** + * Token index for the substring fallback: one precompiled whole-word matcher + * per distinctive token. Word boundaries are any non-alphanumeric char (or the + * string ends), so hyphenated/dotted tokens like "q6155-e" or "h.264" match as + * whole words while "700" never matches inside "7000". + */ +const TOKEN_INDEX = RECORDS.flatMap((record) => + distinctiveTokens(record).map((token) => ({ + token, + record, + re: new RegExp( + `(?:^|[^a-z0-9])${token.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}(?:[^a-z0-9]|$)`, + ), + })), +); + +/** + * Looks up a spec record for a hardware-model string. + * + * Exact match first (case-insensitive, whitespace-collapsed) on the model and + * every alias. When that misses, a token fallback lets a VERBOSE registry + * string resolve — e.g. "AXIS Q6155-E PTZ Dome Network Camera" finds the + * Q6155-E via its distinctive "q6155-e" token. The most specific (longest) + * matched token wins; if two different records tie on the longest matched + * token the result is ambiguous and returns null (never guesses). * @param {string} model - Hardware model as named by a source pack or seed. * @returns {Object|null} The verbatim spec record, or null when unknown. */ export function lookupModelSpec(model) { if (!model) return null; - return BY_MODEL.get(String(model).trim().toLowerCase()) || null; + const query = normalize(model); + if (!query) return null; + const exact = BY_MODEL.get(query); + if (exact) return exact; + const matches = TOKEN_INDEX.filter((entry) => entry.re.test(query)); + if (!matches.length) return null; + const maxLen = Math.max(...matches.map((m) => m.token.length)); + const top = matches.filter((m) => m.token.length === maxLen); + const records = new Set(top.map((m) => m.record)); + return records.size === 1 ? top[0].record : null; } /** diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index b31f2d714..c504e14ad 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -36,6 +36,43 @@ test('vendored table covers the integrated catalogs published hardware', () => { assert.equal(horizontalFovDeg(axis), 66.7); }); +test('verbose registry strings resolve via distinctive-token fallback', () => { + // Sioux Falls publishes the full product title, not the bare model. + assert.equal( + lookupModelSpec('AXIS Q6155-E PTZ Dome Network Camera')?.model, + 'Q6155-E', + ); + assert.equal(lookupModelSpec('AXIS Q6000-E Mk II')?.model, 'Q6000-E Mk II'); + assert.equal( + lookupModelSpec('AUTODOME IP starlight 7000i camera')?.id, + 'bosch-ndp-7512-z30', + ); + // Punctuation around the model token doesn't defeat the match. + assert.equal( + lookupModelSpec('camera (P3707-PE), outdoor')?.model, + 'P3707-PE', + ); +}); + +test('token fallback never matches on generic words or wrong models', () => { + // Only generic descriptors — no distinctive hardware token — stays null. + assert.equal(lookupModelSpec('PTZ Dome Camera'), null); + assert.equal(lookupModelSpec('HD Network Camera'), null); + assert.equal(lookupModelSpec('Outdoor Dome Camera'), null); + // A real-looking but unknown model resolves to nothing, not a near neighbour. + assert.equal(lookupModelSpec('AXIS FooBar-9 Network Camera'), null); + // A generation token alone must not collide with a similar one: "7000" + // belongs to two records (dynamic / starlight 7000 HD), so a bare + // "AUTODOME 7000" is ambiguous → null rather than an arbitrary pick. + assert.equal(lookupModelSpec('AUTODOME 7000'), null); +}); + +test('exact match still wins over the token fallback', () => { + // "3965" is an alias of the 3960; exact alias match, not a token scan. + assert.equal(lookupModelSpec('3965')?.id, 'cohu-3960'); + assert.equal(lookupModelSpec('AUTODOME IP 5000i')?.id, 'bosch-ndp-5502-30'); +}); + test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); assert.equal(lookupModelSpec(''), null);