feat(devicemanager): Introduce a UniversalDevice architecture with composable Feature system; add extensive new device/protocol support and discovery/refactors
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user