285 lines
11 KiB
TypeScript
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(':');
|
|
};
|