Files
devicemanager/ts/discovery/discovery.classes.mdns.ts

314 lines
7.5 KiB
TypeScript
Raw Normal View History

2026-01-09 07:14:39 +00:00
import * as plugins from '../plugins.js';
import type {
IDiscoveredDevice,
IDiscoveryOptions,
TDeviceType,
TScannerProtocol,
} from '../interfaces/index.js';
/**
* Service type definitions for mDNS discovery
*/
const SERVICE_TYPES = {
// Scanners
ESCL: '_uscan._tcp', // eSCL/AirScan scanners
ESCL_SECURE: '_uscans._tcp', // eSCL over TLS
SANE: '_scanner._tcp', // SANE network scanners
// Printers
IPP: '_ipp._tcp', // IPP printers
IPPS: '_ipps._tcp', // IPP over TLS
PDL: '_pdl-datastream._tcp', // Raw/JetDirect printers
} as const;
/**
* Default discovery options
*/
const DEFAULT_OPTIONS: Required<IDiscoveryOptions> = {
serviceTypes: [
SERVICE_TYPES.ESCL,
SERVICE_TYPES.ESCL_SECURE,
SERVICE_TYPES.SANE,
SERVICE_TYPES.IPP,
SERVICE_TYPES.IPPS,
],
timeout: 10000,
};
/**
* mDNS/Bonjour discovery service for network devices
*/
export class MdnsDiscovery extends plugins.events.EventEmitter {
private bonjour: plugins.bonjourService.Bonjour | null = null;
private browsers: plugins.bonjourService.Browser[] = [];
private discoveredDevices: Map<string, IDiscoveredDevice> = new Map();
private options: Required<IDiscoveryOptions>;
private isRunning: boolean = false;
constructor(options?: IDiscoveryOptions) {
super();
this.options = { ...DEFAULT_OPTIONS, ...options };
}
/**
* Check if discovery is currently running
*/
public get running(): boolean {
return this.isRunning;
}
/**
* Get all discovered devices
*/
public getDevices(): IDiscoveredDevice[] {
return Array.from(this.discoveredDevices.values());
}
/**
* Get discovered scanners only
*/
public getScanners(): IDiscoveredDevice[] {
return this.getDevices().filter((d) => d.type === 'scanner');
}
/**
* Get discovered printers only
*/
public getPrinters(): IDiscoveredDevice[] {
return this.getDevices().filter((d) => d.type === 'printer');
}
/**
* Start mDNS discovery
*/
public async start(): Promise<void> {
if (this.isRunning) {
return;
}
this.bonjour = new plugins.bonjourService.Bonjour();
this.isRunning = true;
this.emit('started');
for (const serviceType of this.options.serviceTypes) {
this.browseService(serviceType);
}
}
/**
* Stop mDNS discovery
*/
public async stop(): Promise<void> {
if (!this.isRunning) {
return;
}
// Stop all browsers
for (const browser of this.browsers) {
browser.stop();
}
this.browsers = [];
// Destroy bonjour instance
if (this.bonjour) {
this.bonjour.destroy();
this.bonjour = null;
}
this.isRunning = false;
this.emit('stopped');
}
/**
* Clear discovered devices
*/
public clear(): void {
this.discoveredDevices.clear();
}
/**
* Browse for a specific service type
*/
private browseService(serviceType: string): void {
if (!this.bonjour) {
return;
}
const browser = this.bonjour.find({ type: serviceType }, (service) => {
this.handleServiceFound(service, serviceType);
});
browser.on('down', (service) => {
this.handleServiceLost(service, serviceType);
});
this.browsers.push(browser);
}
/**
* Handle discovered service
*/
private handleServiceFound(
service: plugins.bonjourService.Service,
serviceType: string
): void {
const device = this.parseService(service, serviceType);
if (!device) {
return;
}
const existingDevice = this.discoveredDevices.get(device.id);
if (existingDevice) {
// Update existing device
this.discoveredDevices.set(device.id, device);
this.emit('device:updated', device);
} else {
// New device found
this.discoveredDevices.set(device.id, device);
this.emit('device:found', device);
if (device.type === 'scanner') {
this.emit('scanner:found', device);
} else if (device.type === 'printer') {
this.emit('printer:found', device);
}
}
}
/**
* Handle lost service
*/
private handleServiceLost(
service: plugins.bonjourService.Service,
serviceType: string
): void {
const deviceId = this.generateDeviceId(service, serviceType);
const device = this.discoveredDevices.get(deviceId);
if (device) {
this.discoveredDevices.delete(deviceId);
this.emit('device:lost', device);
if (device.type === 'scanner') {
this.emit('scanner:lost', deviceId);
} else if (device.type === 'printer') {
this.emit('printer:lost', deviceId);
}
}
}
/**
* Parse Bonjour service into device info
*/
private parseService(
service: plugins.bonjourService.Service,
serviceType: string
): IDiscoveredDevice | null {
const addresses = service.addresses ?? [];
// Prefer IPv4 address
const address =
addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host;
if (!address) {
return null;
}
const txtRecords = this.parseTxtRecords(service.txt);
const deviceType = this.getDeviceType(serviceType);
const protocol = this.getProtocol(serviceType);
const deviceId = this.generateDeviceId(service, serviceType);
return {
id: deviceId,
name: service.name || txtRecords['ty'] || txtRecords['product'] || 'Unknown Device',
type: deviceType,
protocol: protocol,
address: address,
port: service.port,
txtRecords: txtRecords,
serviceType: serviceType,
};
}
/**
* Generate unique device ID
*/
private generateDeviceId(
service: plugins.bonjourService.Service,
serviceType: string
): string {
const addresses = service.addresses ?? [];
const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host;
return `${serviceType}:${address}:${service.port}`;
}
/**
* Parse TXT records from service
*/
private parseTxtRecords(
txt: Record<string, unknown> | undefined
): Record<string, string> {
const records: Record<string, string> = {};
if (!txt) {
return records;
}
for (const [key, value] of Object.entries(txt)) {
if (typeof value === 'string') {
records[key] = value;
} else if (Buffer.isBuffer(value)) {
records[key] = value.toString('utf-8');
} else if (value !== undefined && value !== null) {
records[key] = String(value);
}
}
return records;
}
/**
* Determine device type from service type
*/
private getDeviceType(serviceType: string): TDeviceType {
switch (serviceType) {
case SERVICE_TYPES.ESCL:
case SERVICE_TYPES.ESCL_SECURE:
case SERVICE_TYPES.SANE:
return 'scanner';
case SERVICE_TYPES.IPP:
case SERVICE_TYPES.IPPS:
case SERVICE_TYPES.PDL:
return 'printer';
default:
// Check if it's a scanner or printer based on service type pattern
if (serviceType.includes('scan') || serviceType.includes('scanner')) {
return 'scanner';
}
return 'printer';
}
}
/**
* Determine protocol from service type
*/
private getProtocol(serviceType: string): TScannerProtocol | 'ipp' {
switch (serviceType) {
case SERVICE_TYPES.ESCL:
case SERVICE_TYPES.ESCL_SECURE:
return 'escl';
case SERVICE_TYPES.SANE:
return 'sane';
case SERVICE_TYPES.IPP:
case SERVICE_TYPES.IPPS:
case SERVICE_TYPES.PDL:
return 'ipp';
default:
return 'ipp';
}
}
}
export { SERVICE_TYPES };