Files
integrations/ts/integrations/onvif/onvif.discovery.ts
T

285 lines
11 KiB
TypeScript

import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type {
IDiscoveryCandidate,
IDiscoveryContext,
IDiscoveryMatch,
IDiscoveryMatcher,
IDiscoveryValidator,
} from '../../core/types.js';
import type { IOnvifManualDiscoveryRecord, IOnvifMdnsRecord, IOnvifWsDiscoveryRecord } from './onvif.types.js';
import { onvifDefaultPort } from './onvif.types.js';
export class OnvifWsDiscoveryMatcher implements IDiscoveryMatcher<IOnvifWsDiscoveryRecord> {
public id = 'onvif-ws-discovery-match';
public source = 'custom' as const;
public description = 'Recognize ONVIF WS-Discovery ProbeMatch records for NetworkVideoTransmitter devices.';
public async matches(recordArg: IOnvifWsDiscoveryRecord): Promise<IDiscoveryMatch> {
const xaddrs = this.list(recordArg.xaddrs ?? recordArg.xAddrs ?? recordArg.XAddrs);
const scopes = this.scopeValues(recordArg.scopes);
const types = this.list(recordArg.types);
const epr = recordArg.epr || recordArg.endpointReference || recordArg.endpoint_reference;
const firstUrl = this.firstUrl(xaddrs);
const scopeInfo = this.infoFromScopes(scopes);
const hasOnvifType = types.some((typeArg) => /NetworkVideoTransmitter|onvif/i.test(typeArg));
const hasOnvifScope = scopes.some((scopeArg) => /onvif:\/\/www\.onvif\.org\/Profile\/Streaming/i.test(scopeArg));
const hasOnvifXaddr = xaddrs.some((xaddrArg) => /\/onvif\//i.test(xaddrArg));
if (!hasOnvifType && !hasOnvifScope && !hasOnvifXaddr) {
return { matched: false, confidence: 'low', reason: 'WS-Discovery record does not contain ONVIF type, streaming scope, or ONVIF XAddr.' };
}
const normalizedDeviceId = normalizeMac(scopeInfo.macAddress) || this.normalizedEpr(epr) || firstUrl?.host;
return {
matched: true,
confidence: hasOnvifScope || hasOnvifType ? 'certain' : 'high',
reason: 'WS-Discovery record advertises an ONVIF camera service.',
normalizedDeviceId,
candidate: {
source: 'custom',
integrationDomain: 'onvif',
id: normalizedDeviceId,
host: firstUrl?.hostname,
port: firstUrl?.port,
name: scopeInfo.name || recordArg.name || epr,
manufacturer: scopeInfo.manufacturer,
model: scopeInfo.hardware,
macAddress: normalizeMac(scopeInfo.macAddress),
metadata: {
discoveryProtocol: 'ws-discovery',
endpointReference: epr,
xaddrs,
scopes,
serviceTypes: types,
transport: firstUrl?.protocol,
raw: recordArg.metadata,
},
},
metadata: {
discoveryProtocol: 'ws-discovery',
xaddrs,
scopes,
},
};
}
private list(valueArg: string[] | string | undefined): string[] {
if (!valueArg) {
return [];
}
if (Array.isArray(valueArg)) {
return valueArg.map((value) => String(value)).filter(Boolean);
}
return String(valueArg).split(/\s+/).filter(Boolean);
}
private scopeValues(valueArg: IOnvifWsDiscoveryRecord['scopes']): string[] {
if (!valueArg) {
return [];
}
if (typeof valueArg === 'string') {
return this.list(valueArg);
}
return valueArg.map((scopeArg) => {
if (typeof scopeArg === 'string') {
return scopeArg;
}
return scopeArg.value || scopeArg.Value || '';
}).filter(Boolean);
}
private firstUrl(xaddrsArg: string[]): { hostname: string; host: string; port: number; protocol: string } | undefined {
for (const xaddr of xaddrsArg) {
try {
const url = new URL(xaddr);
return {
hostname: url.hostname,
host: url.host,
port: url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : onvifDefaultPort,
protocol: url.protocol.replace(':', ''),
};
} catch {
continue;
}
}
return undefined;
}
private infoFromScopes(scopesArg: string[]): { name?: string; hardware?: string; macAddress?: string; manufacturer?: string } {
const info: { name?: string; hardware?: string; macAddress?: string; manufacturer?: string } = {};
for (const scope of scopesArg) {
const lower = scope.toLowerCase();
const value = decodeURIComponent(scope.split('/').pop() || '');
if (lower.startsWith('onvif://www.onvif.org/name')) {
info.name = value;
} else if (lower.startsWith('onvif://www.onvif.org/hardware')) {
info.hardware = value;
} else if (lower.startsWith('onvif://www.onvif.org/mac')) {
info.macAddress = value;
} else if (lower.startsWith('onvif://www.onvif.org/manufacturer')) {
info.manufacturer = value;
}
}
return info;
}
private normalizedEpr(valueArg?: string): string | undefined {
if (!valueArg) {
return undefined;
}
return valueArg.replace(/^urn:uuid:/i, '').trim() || undefined;
}
}
export class OnvifMdnsMatcher implements IDiscoveryMatcher<IOnvifMdnsRecord> {
public id = 'onvif-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize ONVIF-capable camera mDNS records such as _onvif._tcp.local.';
public async matches(recordArg: IOnvifMdnsRecord): Promise<IDiscoveryMatch> {
const txt = { ...(recordArg.properties || {}), ...(recordArg.txt || {}) };
const type = this.stringValue(recordArg.type).toLowerCase();
const name = this.stringValue(recordArg.name);
const host = this.stringValue(recordArg.host || recordArg.hostname || recordArg.addresses?.[0]);
const txtValues = Object.values(txt).map((valueArg) => String(valueArg).toLowerCase());
const txtKeys = Object.keys(txt).map((keyArg) => keyArg.toLowerCase());
const hasOnvifHint = type.includes('_onvif')
|| name.toLowerCase().includes('onvif')
|| txtKeys.some((keyArg) => keyArg.includes('onvif'))
|| txtValues.some((valueArg) => valueArg.includes('onvif'));
if (!hasOnvifHint) {
return { matched: false, confidence: 'low', reason: 'mDNS record does not advertise ONVIF metadata.' };
}
const macAddress = normalizeMac(this.stringValue(txt.mac || txt.macAddress || txt.mac_address || txt.hwaddr));
const normalizedDeviceId = macAddress || this.stringValue(txt.serial || txt.serialNumber || txt.serial_number) || host;
return {
matched: true,
confidence: type.includes('_onvif') ? 'high' : 'medium',
reason: 'mDNS record contains ONVIF camera metadata.',
normalizedDeviceId,
candidate: {
source: 'mdns',
integrationDomain: 'onvif',
id: normalizedDeviceId,
host,
port: recordArg.port || onvifDefaultPort,
name,
manufacturer: this.stringValue(txt.manufacturer || txt.vendor),
model: this.stringValue(txt.model || txt.hardware),
serialNumber: this.stringValue(txt.serial || txt.serialNumber || txt.serial_number),
macAddress,
metadata: {
discoveryProtocol: 'mdns',
type: recordArg.type,
txt,
},
},
};
}
private stringValue(valueArg: unknown): string {
return typeof valueArg === 'string' ? valueArg.trim() : valueArg === undefined || valueArg === null ? '' : String(valueArg).trim();
}
}
export class OnvifManualMatcher implements IDiscoveryMatcher<IOnvifManualDiscoveryRecord> {
public id = 'onvif-manual-match';
public source = 'manual' as const;
public description = 'Recognize manually configured ONVIF camera entries and cached snapshots.';
public async matches(recordArg: IOnvifManualDiscoveryRecord): Promise<IDiscoveryMatch> {
const snapshot = recordArg.snapshot || recordArg.discovery?.snapshot;
const host = recordArg.host || snapshot?.host || snapshot?.cameras[0]?.host;
const port = recordArg.port || snapshot?.port || snapshot?.cameras[0]?.port || onvifDefaultPort;
if (!host && !snapshot) {
return { matched: false, confidence: 'low', reason: 'Manual ONVIF entries require a host or cached snapshot.' };
}
const deviceInfo = recordArg.deviceInfo || snapshot?.deviceInfo || snapshot?.cameras[0]?.deviceInfo;
const normalizedDeviceId = normalizeMac(deviceInfo?.macAddress) || deviceInfo?.serialNumber || recordArg.id || recordArg.discovery?.id || host;
return {
matched: true,
confidence: snapshot ? 'certain' : 'medium',
reason: snapshot ? 'Manual entry contains an ONVIF snapshot.' : 'Manual entry contains ONVIF host configuration.',
normalizedDeviceId,
candidate: {
source: 'manual',
integrationDomain: 'onvif',
id: normalizedDeviceId,
host,
port,
name: recordArg.name || snapshot?.name || deviceInfo?.name,
manufacturer: deviceInfo?.manufacturer,
model: deviceInfo?.model,
serialNumber: deviceInfo?.serialNumber,
macAddress: normalizeMac(deviceInfo?.macAddress),
metadata: {
discoveryProtocol: 'manual',
snapshot,
profiles: recordArg.profiles,
streams: recordArg.streams,
transport: recordArg.transport || snapshot?.transport || 'http',
...recordArg.metadata,
},
},
};
}
}
export class OnvifCandidateValidator implements IDiscoveryValidator {
public id = 'onvif-candidate-validator';
public description = 'Confirm an ONVIF discovery candidate has a usable host/port or cached snapshot.';
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
void contextArg;
if (candidateArg.integrationDomain && candidateArg.integrationDomain !== 'onvif') {
return { matched: false, confidence: 'low', reason: `Candidate belongs to ${candidateArg.integrationDomain}, not ONVIF.` };
}
const snapshot = candidateArg.metadata?.snapshot;
const port = candidateArg.port || onvifDefaultPort;
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return { matched: false, confidence: 'low', reason: 'ONVIF candidate has an invalid port.' };
}
if (!candidateArg.host && !snapshot) {
return { matched: false, confidence: 'low', reason: 'ONVIF candidate requires host information or a cached snapshot.' };
}
const id = normalizeMac(candidateArg.macAddress) || candidateArg.serialNumber || candidateArg.id || candidateArg.host;
return {
matched: true,
confidence: candidateArg.id || candidateArg.macAddress || snapshot ? 'high' : 'medium',
reason: 'Candidate has enough ONVIF metadata to start configuration.',
candidate: candidateArg,
normalizedDeviceId: id,
metadata: {
discoveryProtocol: candidateArg.metadata?.discoveryProtocol || candidateArg.source,
wsDiscoverySupported: candidateArg.metadata?.discoveryProtocol === 'ws-discovery',
mdnsSupported: candidateArg.source === 'mdns',
manualSupported: candidateArg.source === 'manual',
},
};
}
}
export const createOnvifDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({
integrationDomain: 'onvif',
displayName: 'ONVIF',
})
.addMatcher(new OnvifWsDiscoveryMatcher())
.addMatcher(new OnvifMdnsMatcher())
.addMatcher(new OnvifManualMatcher())
.addValidator(new OnvifCandidateValidator());
};
const normalizeMac = (valueArg?: string): string | undefined => {
if (!valueArg) {
return undefined;
}
const compact = valueArg.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
if (compact.length !== 12) {
return undefined;
}
return compact.match(/.{1,2}/g)?.join(':');
};