feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors

This commit is contained in:
2026-01-09 09:03:42 +00:00
parent 05e1f94c79
commit 206b4b5ae0
33 changed files with 8254 additions and 87 deletions

View File

@@ -1,6 +1,5 @@
import * as plugins from '../plugins.js';
import { EsclProtocol } from '../scanner/scanner.classes.esclprotocol.js';
import { IppProtocol } from '../printer/printer.classes.ippprotocol.js';
import {
cidrToIps,
ipRangeToIps,
@@ -16,7 +15,18 @@ import type {
/**
* Default ports to probe for device discovery
*/
const DEFAULT_PORTS = [631, 80, 443, 6566, 9100];
const DEFAULT_PORTS = [
631, // IPP printers
80, // eSCL scanners (HTTP)
443, // eSCL scanners (HTTPS)
6566, // SANE scanners
9100, // JetDirect printers
7000, // AirPlay speakers
5000, // AirPlay control / RTSP
3689, // DAAP (iTunes/AirPlay 2)
1400, // Sonos speakers
8009, // Chromecast devices
];
/**
* Default scan options
@@ -28,6 +38,9 @@ const DEFAULT_OPTIONS: Required<Omit<INetworkScanOptions, 'ipRange' | 'startIp'
probeEscl: true,
probeIpp: true,
probeSane: true,
probeAirplay: true,
probeSonos: true,
probeChromecast: true,
};
/**
@@ -228,6 +241,43 @@ export class NetworkScanner extends plugins.events.EventEmitter {
name: `Raw Printer at ${ip}`,
});
}
// AirPlay probe (port 7000) - try HTTP endpoints
if (opts.probeAirplay && port === 7000) {
probePromises.push(
this.probeAirplay(ip, port, timeout).then((device) => {
if (device) devices.push(device);
})
);
}
// AirPlay ports 5000 (RTSP) and 3689 (DAAP) - if open, it's likely an AirPlay device
if (opts.probeAirplay && (port === 5000 || port === 3689)) {
devices.push({
type: 'speaker',
protocol: 'airplay',
port,
name: `AirPlay Device at ${ip}`,
});
}
// Sonos probe (port 1400)
if (opts.probeSonos && port === 1400) {
probePromises.push(
this.probeSonos(ip, port, timeout).then((device) => {
if (device) devices.push(device);
})
);
}
// Chromecast probe (port 8009)
if (opts.probeChromecast && port === 8009) {
probePromises.push(
this.probeChromecast(ip, port, timeout).then((device) => {
if (device) devices.push(device);
})
);
}
}
await Promise.all(probePromises);
@@ -281,7 +331,8 @@ export class NetworkScanner extends plugins.events.EventEmitter {
}
/**
* Probe for IPP printer
* Probe for IPP printer using a simple HTTP check
* We avoid using the full ipp library for probing since it can hang and produce noisy output
*/
private async probeIpp(
ip: string,
@@ -289,22 +340,54 @@ export class NetworkScanner extends plugins.events.EventEmitter {
timeout: number
): Promise<INetworkScanDevice | null> {
try {
const ipp = new IppProtocol(ip, port);
const attrs = await Promise.race([
ipp.getAttributes(),
new Promise<null>((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeout)
),
]);
// Use a simple HTTP OPTIONS or POST to check if IPP endpoint exists
// IPP uses HTTP POST to /ipp/print or /ipp/printer
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
if (attrs) {
return {
type: 'printer',
protocol: 'ipp',
port,
name: `IPP Printer at ${ip}`,
model: undefined,
};
try {
const response = await fetch(`http://${ip}:${port}/ipp/print`, {
method: 'POST',
headers: {
'Content-Type': 'application/ipp',
},
body: Buffer.from([
// Minimal IPP Get-Printer-Attributes request
0x01, 0x01, // IPP version 1.1
0x00, 0x0b, // operation-id: Get-Printer-Attributes
0x00, 0x00, 0x00, 0x01, // request-id: 1
0x01, // operation-attributes-tag
0x47, // charset
0x00, 0x12, // name-length: 18
...Buffer.from('attributes-charset'),
0x00, 0x05, // value-length: 5
...Buffer.from('utf-8'),
0x48, // naturalLanguage
0x00, 0x1b, // name-length: 27
...Buffer.from('attributes-natural-language'),
0x00, 0x05, // value-length: 5
...Buffer.from('en-us'),
0x03, // end-of-attributes-tag
]),
signal: controller.signal,
});
clearTimeout(timeoutId);
// If we get a response with application/ipp content type, it's likely an IPP printer
const contentType = response.headers.get('content-type') || '';
if (response.ok || contentType.includes('application/ipp')) {
return {
type: 'printer',
protocol: 'ipp',
port,
name: `IPP Printer at ${ip}`,
model: undefined,
};
}
} catch (fetchErr) {
clearTimeout(timeoutId);
// Fetch failed or was aborted
}
} catch {
// Not an IPP printer
@@ -375,4 +458,191 @@ export class NetworkScanner extends plugins.events.EventEmitter {
return null;
}
/**
* Probe for AirPlay speaker on port 7000
* Tries HTTP endpoints to identify the device. If no HTTP response,
* but port 7000 is open, it's still likely an AirPlay device.
*/
private async probeAirplay(
ip: string,
port: number,
timeout: number
): Promise<INetworkScanDevice | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// Try /server-info first (older AirPlay devices)
try {
const response = await fetch(`http://${ip}:${port}/server-info`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
const text = await response.text();
// Parse model from plist if available
const modelMatch = text.match(/<key>model<\/key>\s*<string>([^<]+)<\/string>/);
const model = modelMatch?.[1];
return {
type: 'speaker',
protocol: 'airplay',
port,
name: model || `AirPlay Speaker at ${ip}`,
model,
};
}
} catch {
clearTimeout(timeoutId);
}
// Try /info endpoint (some AirPlay 2 devices)
const controller2 = new AbortController();
const timeoutId2 = setTimeout(() => controller2.abort(), timeout);
try {
const response = await fetch(`http://${ip}:${port}/info`, {
signal: controller2.signal,
});
clearTimeout(timeoutId2);
if (response.ok) {
const text = await response.text();
// Try to parse model info
const modelMatch = text.match(/<key>model<\/key>\s*<string>([^<]+)<\/string>/);
const nameMatch = text.match(/<key>name<\/key>\s*<string>([^<]+)<\/string>/);
const model = modelMatch?.[1];
const name = nameMatch?.[1];
return {
type: 'speaker',
protocol: 'airplay',
port,
name: name || model || `AirPlay Speaker at ${ip}`,
model,
};
}
} catch {
clearTimeout(timeoutId2);
}
// Port 7000 is open but no HTTP endpoints responded
// Still likely an AirPlay device (AirPlay 2 / HomePod)
return {
type: 'speaker',
protocol: 'airplay',
port,
name: `AirPlay Device at ${ip}`,
};
} catch {
// Not an AirPlay speaker
}
return null;
}
/**
* Probe for Sonos speaker
*/
private async probeSonos(
ip: string,
port: number,
timeout: number
): Promise<INetworkScanDevice | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
// Sonos devices respond to device description requests
const response = await fetch(`http://${ip}:${port}/xml/device_description.xml`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
const text = await response.text();
// Check if it's actually a Sonos device
if (text.includes('Sonos') || text.includes('schemas-upnp-org')) {
// Parse friendly name and model
const nameMatch = text.match(/<friendlyName>([^<]+)<\/friendlyName>/);
const modelMatch = text.match(/<modelName>([^<]+)<\/modelName>/);
return {
type: 'speaker',
protocol: 'sonos',
port,
name: nameMatch?.[1] || `Sonos Speaker at ${ip}`,
model: modelMatch?.[1],
};
}
}
} catch {
clearTimeout(timeoutId);
}
} catch {
// Not a Sonos speaker
}
return null;
}
/**
* Probe for Chromecast device
*/
private async probeChromecast(
ip: string,
port: number,
timeout: number
): Promise<INetworkScanDevice | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
// Chromecast devices have an info endpoint on port 8008 (HTTP)
// Port 8009 is the Cast protocol port (TLS)
// Try fetching the eureka_info endpoint
const response = await fetch(`http://${ip}:8008/setup/eureka_info`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
const data = await response.json();
return {
type: 'speaker',
protocol: 'chromecast',
port,
name: data.name || `Chromecast at ${ip}`,
model: data.cast_build_revision || data.model_name,
};
}
} catch {
clearTimeout(timeoutId);
}
// Alternative: just check if port 8009 is open (Cast protocol)
const isOpen = await this.isPortOpen(ip, port, timeout);
if (isOpen) {
return {
type: 'speaker',
protocol: 'chromecast',
port,
name: `Chromecast at ${ip}`,
};
}
} catch {
// Not a Chromecast
}
return null;
}
}