Add native local infrastructure integrations
This commit is contained in:
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './ipp.classes.client.js';
|
||||
export * from './ipp.classes.configflow.js';
|
||||
export * from './ipp.classes.integration.js';
|
||||
export * from './ipp.discovery.js';
|
||||
export * from './ipp.mapper.js';
|
||||
export * from './ipp.types.js';
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
import type {
|
||||
IIppAttributeRecord,
|
||||
IIppClientLike,
|
||||
IIppConfig,
|
||||
IIppJobInfo,
|
||||
IIppMarkerInfo,
|
||||
IIppParsedResponse,
|
||||
IIppPrinterInfo,
|
||||
IIppSnapshot,
|
||||
IIppStatusInfo,
|
||||
TIppMarkerKind,
|
||||
TIppPrinterState,
|
||||
TIppSnapshotSource,
|
||||
} from './ipp.types.js';
|
||||
import { ippDefaultBasePath, ippDefaultPort, ippDefaultTimeoutMs } from './ipp.types.js';
|
||||
|
||||
const operationGetPrinterAttributes = 0x000b;
|
||||
const groupOperationAttributes = 0x01;
|
||||
const endOfAttributes = 0x03;
|
||||
const valueTagInteger = 0x21;
|
||||
const valueTagBoolean = 0x22;
|
||||
const valueTagEnum = 0x23;
|
||||
const valueTagDateTime = 0x31;
|
||||
const valueTagKeyword = 0x44;
|
||||
const valueTagCharset = 0x47;
|
||||
const valueTagNaturalLanguage = 0x48;
|
||||
|
||||
const requestedPrinterAttributes = [
|
||||
'printer-name',
|
||||
'printer-info',
|
||||
'printer-location',
|
||||
'printer-make-and-model',
|
||||
'printer-device-id',
|
||||
'printer-uuid',
|
||||
'printer-serial-number',
|
||||
'printer-more-info',
|
||||
'printer-uri-supported',
|
||||
'document-format-supported',
|
||||
'printer-state',
|
||||
'printer-state-message',
|
||||
'printer-state-reasons',
|
||||
'printer-is-accepting-jobs',
|
||||
'queued-job-count',
|
||||
'printer-up-time',
|
||||
'printer-current-time',
|
||||
'marker-names',
|
||||
'marker-types',
|
||||
'marker-colors',
|
||||
'marker-levels',
|
||||
'marker-low-levels',
|
||||
'marker-high-levels',
|
||||
];
|
||||
|
||||
export class IppClient {
|
||||
constructor(private readonly config: IIppConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<IIppSnapshot> {
|
||||
if (this.config.snapshot) {
|
||||
return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot), 'snapshot');
|
||||
}
|
||||
|
||||
if (this.config.client) {
|
||||
return this.normalizeSnapshot(await this.snapshotFromClient(this.config.client), 'client');
|
||||
}
|
||||
|
||||
if (this.config.attributes) {
|
||||
return this.normalizeSnapshot(this.snapshotFromAttributes(this.config.attributes, this.config.online ?? true, 'manual'), 'manual');
|
||||
}
|
||||
|
||||
if (this.config.host) {
|
||||
try {
|
||||
return this.normalizeSnapshot(await this.fetchSnapshot(), 'ipp');
|
||||
} catch (errorArg) {
|
||||
return this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime');
|
||||
}
|
||||
}
|
||||
|
||||
return this.normalizeSnapshot(this.snapshotFromConfig(false, 'IPP refresh requires config.host, config.snapshot, config.attributes, or config.client.'), 'runtime');
|
||||
}
|
||||
|
||||
public async refresh(): Promise<IIppSnapshot> {
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
public async ping(): Promise<boolean> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
return snapshot.online && snapshot.source !== 'runtime' && !snapshot.error;
|
||||
}
|
||||
|
||||
public hasUsableSource(): boolean {
|
||||
return Boolean(this.config.host || this.config.snapshot || this.config.attributes || this.config.client);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
public static parseResponse(dataArg: Uint8Array): IIppParsedResponse {
|
||||
if (dataArg.length < 8) {
|
||||
throw new Error('IPP response is too short.');
|
||||
}
|
||||
|
||||
const data = Buffer.from(dataArg.buffer, dataArg.byteOffset, dataArg.byteLength);
|
||||
const attributes: IIppAttributeRecord = {};
|
||||
const version = `${data[0]}.${data[1]}`;
|
||||
const statusCode = data.readUInt16BE(2);
|
||||
const requestId = data.readUInt32BE(4);
|
||||
let offset = 8;
|
||||
let lastName = '';
|
||||
|
||||
while (offset < data.length) {
|
||||
const tag = data[offset++];
|
||||
if (tag === endOfAttributes) {
|
||||
break;
|
||||
}
|
||||
if (isDelimiterTag(tag)) {
|
||||
lastName = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (offset + 2 > data.length) {
|
||||
throw new Error('IPP response ended while reading attribute name length.');
|
||||
}
|
||||
const nameLength = data.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
if (offset + nameLength + 2 > data.length) {
|
||||
throw new Error('IPP response ended while reading attribute name.');
|
||||
}
|
||||
const name = nameLength ? data.subarray(offset, offset + nameLength).toString('utf8') : lastName;
|
||||
offset += nameLength;
|
||||
const valueLength = data.readUInt16BE(offset);
|
||||
offset += 2;
|
||||
if (offset + valueLength > data.length) {
|
||||
throw new Error('IPP response ended while reading attribute value.');
|
||||
}
|
||||
const valueBytes = data.subarray(offset, offset + valueLength);
|
||||
offset += valueLength;
|
||||
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
lastName = name;
|
||||
addAttributeValue(attributes, name, parseIppValue(tag, valueBytes));
|
||||
}
|
||||
|
||||
return { version, statusCode, requestId, attributes };
|
||||
}
|
||||
|
||||
public static attributesToSnapshot(attributesArg: IIppAttributeRecord, configArg: Partial<IIppConfig> = {}, onlineArg = true, sourceArg: TIppSnapshotSource = 'manual', statusCodeArg?: number): IIppSnapshot {
|
||||
return new IppClient(configArg).snapshotFromAttributes(attributesArg, onlineArg, sourceArg, statusCodeArg);
|
||||
}
|
||||
|
||||
private async snapshotFromClient(clientArg: IIppClientLike): Promise<IIppSnapshot> {
|
||||
const result = clientArg.getSnapshot ? await clientArg.getSnapshot() : clientArg.printer ? await clientArg.printer() : undefined;
|
||||
if (!result) {
|
||||
throw new Error('IPP client must expose getSnapshot() or printer().');
|
||||
}
|
||||
if (isIppSnapshot(result)) {
|
||||
return result;
|
||||
}
|
||||
return this.snapshotFromAttributes(result, true, 'client');
|
||||
}
|
||||
|
||||
private async fetchSnapshot(): Promise<IIppSnapshot> {
|
||||
const requestBody = this.buildGetPrinterAttributesRequest();
|
||||
const response = await this.fetchWithTimeout(this.endpointUrl(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/ipp',
|
||||
'content-type': 'application/ipp',
|
||||
},
|
||||
body: requestBody.buffer.slice(requestBody.byteOffset, requestBody.byteOffset + requestBody.byteLength) as ArrayBuffer,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`IPP printer request failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
|
||||
}
|
||||
|
||||
const parsed = IppClient.parseResponse(new Uint8Array(await response.arrayBuffer()));
|
||||
if (parsed.statusCode < 0x0000 || parsed.statusCode >= 0x0400) {
|
||||
throw new Error(`IPP printer returned status 0x${parsed.statusCode.toString(16).padStart(4, '0')}.`);
|
||||
}
|
||||
return this.snapshotFromAttributes(parsed.attributes, true, 'ipp', parsed.statusCode);
|
||||
}
|
||||
|
||||
private snapshotFromAttributes(attributesArg: IIppAttributeRecord, onlineArg: boolean, sourceArg: TIppSnapshotSource, statusCodeArg?: number): IIppSnapshot {
|
||||
const attributes = normalizeAttributes(attributesArg);
|
||||
const updatedAt = new Date().toISOString();
|
||||
const deviceId = stringValue(firstValue(attributes['printer-device-id']));
|
||||
const deviceInfo = parseDeviceId(deviceId);
|
||||
const makeAndModel = stringValue(firstValue(attributes['printer-make-and-model']));
|
||||
const uuid = this.config.uuid || stringValue(firstValue(attributes['printer-uuid']));
|
||||
const serialNumber = this.config.serial || stringValue(firstValue(attributes['printer-serial-number'])) || deviceInfo.SERIALNUMBER || deviceInfo.SN || deviceInfo.SERN;
|
||||
const printerState = printerStateValue(firstValue(attributes['printer-state']));
|
||||
const currentTime = dateString(firstValue(attributes['printer-current-time']));
|
||||
const uptimeSeconds = numberValue(firstValue(attributes['printer-up-time']));
|
||||
const bootedAt = uptimeSeconds !== undefined ? new Date(Date.parse(currentTime || updatedAt) - uptimeSeconds * 1000).toISOString() : undefined;
|
||||
const parsed = parsedHost(this.config.host);
|
||||
const host = parsed?.host || this.config.host;
|
||||
const port = this.config.port || parsed?.port || (host ? ippDefaultPort : undefined);
|
||||
const basePath = this.basePath();
|
||||
const name = this.config.name || stringValue(firstValue(attributes['printer-name'])) || stringValue(firstValue(attributes['printer-info'])) || makeAndModel || host || 'IPP printer';
|
||||
const printer: IIppPrinterInfo = {
|
||||
id: this.config.uniqueId || uuid || serialNumber || (host ? `${host}:${port}${basePath}` : undefined) || name,
|
||||
name,
|
||||
manufacturer: this.config.manufacturer || deviceInfo.MFG || deviceInfo.MANUFACTURER || manufacturerFromMakeAndModel(makeAndModel),
|
||||
model: this.config.model || deviceInfo.MDL || deviceInfo.MODEL || makeAndModel,
|
||||
serialNumber,
|
||||
uuid,
|
||||
version: stringValue(firstValue(attributes['printer-firmware-name'])) || deviceInfo.VERSION,
|
||||
location: stringValue(firstValue(attributes['printer-location'])),
|
||||
info: stringValue(firstValue(attributes['printer-info'])),
|
||||
moreInfo: stringValue(firstValue(attributes['printer-more-info'])),
|
||||
makeAndModel,
|
||||
deviceId,
|
||||
commandSet: splitList(deviceInfo.CMD || deviceInfo.COMMANDSET || deviceInfo['COMMAND SET']),
|
||||
uriSupported: stringValues(attributes['printer-uri-supported']),
|
||||
host,
|
||||
port,
|
||||
basePath,
|
||||
tls: this.tls(),
|
||||
};
|
||||
const status: IIppStatusInfo = {
|
||||
printerState,
|
||||
stateMessage: stringValue(firstValue(attributes['printer-state-message'])),
|
||||
stateReasons: stringValues(attributes['printer-state-reasons']),
|
||||
acceptingJobs: booleanValue(firstValue(attributes['printer-is-accepting-jobs'])),
|
||||
queuedJobCount: numberValue(firstValue(attributes['queued-job-count'])),
|
||||
uptimeSeconds,
|
||||
bootedAt,
|
||||
currentTime,
|
||||
};
|
||||
|
||||
return {
|
||||
printer,
|
||||
status,
|
||||
markers: this.markersFromAttributes(attributes),
|
||||
jobs: this.jobsFromAttributes(attributes),
|
||||
attributes,
|
||||
online: onlineArg,
|
||||
updatedAt,
|
||||
source: sourceArg,
|
||||
rawStatusCode: statusCodeArg,
|
||||
};
|
||||
}
|
||||
|
||||
private snapshotFromConfig(onlineArg: boolean, errorArg?: string): IIppSnapshot {
|
||||
const parsed = parsedHost(this.config.host);
|
||||
const host = parsed?.host || this.config.host;
|
||||
const port = this.config.port || parsed?.port || (host ? ippDefaultPort : undefined);
|
||||
const basePath = this.basePath();
|
||||
const name = this.config.name || this.config.model || host || 'IPP printer';
|
||||
return {
|
||||
printer: {
|
||||
id: this.config.uniqueId || this.config.uuid || this.config.serial || (host ? `${host}:${port}${basePath}` : undefined) || name,
|
||||
name,
|
||||
manufacturer: this.config.manufacturer,
|
||||
model: this.config.model,
|
||||
serialNumber: this.config.serial,
|
||||
uuid: this.config.uuid,
|
||||
host,
|
||||
port,
|
||||
basePath,
|
||||
tls: this.tls(),
|
||||
},
|
||||
status: {
|
||||
printerState: 'unknown',
|
||||
stateReasons: [],
|
||||
},
|
||||
markers: [],
|
||||
jobs: [],
|
||||
online: onlineArg,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: 'runtime',
|
||||
error: errorArg,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IIppSnapshot, sourceArg: TIppSnapshotSource): IIppSnapshot {
|
||||
const derived = snapshotArg.attributes ? this.snapshotFromAttributes(snapshotArg.attributes, snapshotArg.online, sourceArg, snapshotArg.rawStatusCode) : undefined;
|
||||
const printer = {
|
||||
...derived?.printer,
|
||||
...snapshotArg.printer,
|
||||
};
|
||||
printer.name = printer.name || this.config.name || this.config.host || 'IPP printer';
|
||||
printer.id = printer.id || this.config.uniqueId || printer.uuid || printer.serialNumber || printer.name;
|
||||
printer.host = printer.host || this.config.host;
|
||||
printer.port = printer.port || (printer.host ? this.config.port || ippDefaultPort : this.config.port);
|
||||
printer.basePath = printer.basePath || this.basePath();
|
||||
printer.tls = printer.tls ?? this.tls();
|
||||
|
||||
return {
|
||||
...snapshotArg,
|
||||
printer,
|
||||
status: this.normalizeStatus(derived?.status, snapshotArg.status),
|
||||
markers: snapshotArg.markers?.length ? snapshotArg.markers : derived?.markers || [],
|
||||
jobs: snapshotArg.jobs?.length ? snapshotArg.jobs : derived?.jobs || [],
|
||||
attributes: snapshotArg.attributes || derived?.attributes,
|
||||
online: snapshotArg.online,
|
||||
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||
source: snapshotArg.source || sourceArg,
|
||||
};
|
||||
}
|
||||
|
||||
private markersFromAttributes(attributesArg: IIppAttributeRecord): IIppMarkerInfo[] {
|
||||
const names = stringValues(attributesArg['marker-names']);
|
||||
const types = stringValues(attributesArg['marker-types']);
|
||||
const colors = stringValues(attributesArg['marker-colors']);
|
||||
const levels = numberValues(attributesArg['marker-levels']);
|
||||
const lowLevels = numberValues(attributesArg['marker-low-levels']);
|
||||
const highLevels = numberValues(attributesArg['marker-high-levels']);
|
||||
const count = Math.max(names.length, types.length, colors.length, levels.length, lowLevels.length, highLevels.length);
|
||||
const markers: IIppMarkerInfo[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const type = types[index];
|
||||
const level = levels[index];
|
||||
markers.push({
|
||||
id: `marker_${index}`,
|
||||
index,
|
||||
name: names[index] || markerName(type, colors[index], index),
|
||||
kind: markerKind(type, names[index]),
|
||||
type,
|
||||
color: colors[index],
|
||||
level: level !== undefined && level >= 0 ? level : level !== undefined ? null : undefined,
|
||||
lowLevel: lowLevels[index],
|
||||
highLevel: highLevels[index],
|
||||
});
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
private jobsFromAttributes(attributesArg: IIppAttributeRecord): IIppJobInfo[] {
|
||||
const ids = stringValues(attributesArg['job-id']);
|
||||
const names = stringValues(attributesArg['job-name']);
|
||||
const states = values(attributesArg['job-state']).map((valueArg) => jobStateValue(valueArg));
|
||||
const owners = stringValues(attributesArg['job-originating-user-name']);
|
||||
const impressionsCompleted = numberValues(attributesArg['job-impressions-completed']);
|
||||
const createdAt = values(attributesArg['time-at-creation']).map((valueArg) => dateString(valueArg));
|
||||
const processingAt = values(attributesArg['time-at-processing']).map((valueArg) => dateString(valueArg));
|
||||
const completedAt = values(attributesArg['time-at-completed']).map((valueArg) => dateString(valueArg));
|
||||
const count = Math.max(ids.length, names.length, states.length, owners.length, impressionsCompleted.length, createdAt.length, processingAt.length, completedAt.length);
|
||||
const jobs: IIppJobInfo[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
jobs.push({
|
||||
id: ids[index] || String(index + 1),
|
||||
name: names[index],
|
||||
state: states[index],
|
||||
owner: owners[index],
|
||||
impressionsCompleted: impressionsCompleted[index],
|
||||
createdAt: createdAt[index],
|
||||
processingAt: processingAt[index],
|
||||
completedAt: completedAt[index],
|
||||
});
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
private buildGetPrinterAttributesRequest(): Buffer {
|
||||
const chunks: Buffer[] = [];
|
||||
chunks.push(Buffer.from([0x01, 0x01]));
|
||||
chunks.push(uint16(operationGetPrinterAttributes));
|
||||
chunks.push(uint32(Math.floor(Math.random() * 0x7fffffff) + 1));
|
||||
chunks.push(Buffer.from([groupOperationAttributes]));
|
||||
writeAttribute(chunks, valueTagCharset, 'attributes-charset', 'utf-8');
|
||||
writeAttribute(chunks, valueTagNaturalLanguage, 'attributes-natural-language', 'en');
|
||||
writeAttribute(chunks, 0x45, 'printer-uri', this.printerUri());
|
||||
requestedPrinterAttributes.forEach((attributeArg, indexArg) => writeAttribute(chunks, valueTagKeyword, indexArg === 0 ? 'requested-attributes' : '', attributeArg));
|
||||
chunks.push(Buffer.from([endOfAttributes]));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
private endpointUrl(): string {
|
||||
const host = this.config.host;
|
||||
if (!host) {
|
||||
throw new Error('IPP host is required for live printer refresh.');
|
||||
}
|
||||
const parsed = parsedHost(host);
|
||||
const tls = parsed?.tls ?? this.tls();
|
||||
const endpointHost = parsed?.host || host;
|
||||
const port = this.config.port || parsed?.port || ippDefaultPort;
|
||||
const basePath = this.config.basePath || parsed?.basePath || ippDefaultBasePath;
|
||||
return `${tls ? 'https' : 'http'}://${formatHost(endpointHost)}:${port}${normalizeBasePath(basePath)}`;
|
||||
}
|
||||
|
||||
private printerUri(): string {
|
||||
const host = this.config.host;
|
||||
if (!host) {
|
||||
throw new Error('IPP host is required for printer-uri.');
|
||||
}
|
||||
const parsed = parsedHost(host);
|
||||
const tls = parsed?.tls ?? this.tls();
|
||||
const endpointHost = parsed?.host || host;
|
||||
const port = this.config.port || parsed?.port || ippDefaultPort;
|
||||
const basePath = this.config.basePath || parsed?.basePath || ippDefaultBasePath;
|
||||
return `${tls ? 'ipps' : 'ipp'}://${formatHost(endpointHost)}:${port}${normalizeBasePath(basePath)}`;
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise<Response> {
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || ippDefaultTimeoutMs);
|
||||
try {
|
||||
return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private basePath(): string {
|
||||
return normalizeBasePath(this.config.basePath || parsedHost(this.config.host)?.basePath || ippDefaultBasePath);
|
||||
}
|
||||
|
||||
private tls(): boolean {
|
||||
return this.config.tls ?? this.config.ssl ?? parsedHost(this.config.host)?.tls ?? false;
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IIppSnapshot): IIppSnapshot {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as IIppSnapshot;
|
||||
}
|
||||
|
||||
private normalizeStatus(derivedArg: IIppStatusInfo | undefined, snapshotArg: IIppStatusInfo): IIppStatusInfo {
|
||||
return {
|
||||
...derivedArg,
|
||||
...snapshotArg,
|
||||
printerState: snapshotArg.printerState || derivedArg?.printerState || 'unknown',
|
||||
stateReasons: snapshotArg.stateReasons || derivedArg?.stateReasons || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const isDelimiterTag = (tagArg: number): boolean => tagArg >= 0x01 && tagArg <= 0x0f;
|
||||
|
||||
const writeAttribute = (chunksArg: Buffer[], tagArg: number, nameArg: string, valueArg: string): void => {
|
||||
const name = Buffer.from(nameArg, 'utf8');
|
||||
const value = Buffer.from(valueArg, 'utf8');
|
||||
chunksArg.push(Buffer.from([tagArg]));
|
||||
chunksArg.push(uint16(name.length));
|
||||
chunksArg.push(name);
|
||||
chunksArg.push(uint16(value.length));
|
||||
chunksArg.push(value);
|
||||
};
|
||||
|
||||
const uint16 = (valueArg: number): Buffer => {
|
||||
const buffer = Buffer.alloc(2);
|
||||
buffer.writeUInt16BE(valueArg, 0);
|
||||
return buffer;
|
||||
};
|
||||
|
||||
const uint32 = (valueArg: number): Buffer => {
|
||||
const buffer = Buffer.alloc(4);
|
||||
buffer.writeUInt32BE(valueArg, 0);
|
||||
return buffer;
|
||||
};
|
||||
|
||||
const parseIppValue = (tagArg: number, valueArg: Buffer): unknown => {
|
||||
if (tagArg === 0x10 || tagArg === 0x12 || tagArg === 0x13) {
|
||||
return null;
|
||||
}
|
||||
if ((tagArg === valueTagInteger || tagArg === valueTagEnum) && valueArg.length >= 4) {
|
||||
return valueArg.readInt32BE(0);
|
||||
}
|
||||
if (tagArg === valueTagBoolean && valueArg.length >= 1) {
|
||||
return valueArg[0] !== 0;
|
||||
}
|
||||
if (tagArg === valueTagDateTime && valueArg.length >= 11) {
|
||||
return parseIppDateTime(valueArg);
|
||||
}
|
||||
if (tagArg === 0x33 && valueArg.length >= 8) {
|
||||
return { lower: valueArg.readInt32BE(0), upper: valueArg.readInt32BE(4) };
|
||||
}
|
||||
if (tagArg === 0x32 && valueArg.length >= 9) {
|
||||
return { x: valueArg.readInt32BE(0), y: valueArg.readInt32BE(4), units: valueArg[8] === 3 ? 'dpi' : 'dpcm' };
|
||||
}
|
||||
if (tagArg >= 0x40 || tagArg === 0x30 || tagArg === 0x35 || tagArg === 0x36) {
|
||||
return valueArg.toString('utf8');
|
||||
}
|
||||
return valueArg.toString('hex');
|
||||
};
|
||||
|
||||
const parseIppDateTime = (valueArg: Buffer): string | undefined => {
|
||||
const year = valueArg.readUInt16BE(0);
|
||||
const month = valueArg[2];
|
||||
const day = valueArg[3];
|
||||
const hour = valueArg[4];
|
||||
const minute = valueArg[5];
|
||||
const second = valueArg[6];
|
||||
const decisecond = valueArg[7];
|
||||
const direction = String.fromCharCode(valueArg[8]);
|
||||
const offsetMinutes = valueArg[9] * 60 + valueArg[10];
|
||||
const local = Date.UTC(year, Math.max(0, month - 1), day, hour, minute, second, decisecond * 100);
|
||||
const adjustment = direction === '+' ? -offsetMinutes : direction === '-' ? offsetMinutes : 0;
|
||||
const timestamp = local + adjustment * 60_000;
|
||||
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined;
|
||||
};
|
||||
|
||||
const addAttributeValue = (attributesArg: IIppAttributeRecord, nameArg: string, valueArg: unknown): void => {
|
||||
const existing = attributesArg[nameArg];
|
||||
if (existing === undefined) {
|
||||
attributesArg[nameArg] = valueArg;
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(existing)) {
|
||||
existing.push(valueArg);
|
||||
return;
|
||||
}
|
||||
attributesArg[nameArg] = [existing, valueArg];
|
||||
};
|
||||
|
||||
const normalizeAttributes = (attributesArg: IIppAttributeRecord): IIppAttributeRecord => {
|
||||
const normalized: IIppAttributeRecord = {};
|
||||
for (const [key, value] of Object.entries(attributesArg || {})) {
|
||||
normalized[key.trim()] = value;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const isIppSnapshot = (valueArg: IIppSnapshot | IIppAttributeRecord): valueArg is IIppSnapshot => {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && 'printer' in valueArg && 'status' in valueArg && 'markers' in valueArg && 'online' in valueArg);
|
||||
};
|
||||
|
||||
const values = (valueArg: unknown): unknown[] => Array.isArray(valueArg) ? valueArg : valueArg === undefined ? [] : [valueArg];
|
||||
|
||||
const firstValue = (valueArg: unknown): unknown => values(valueArg)[0];
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => {
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
return valueArg.trim();
|
||||
}
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return String(valueArg);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const stringValues = (valueArg: unknown): string[] => values(valueArg).map((itemArg) => stringValue(itemArg)).filter((itemArg): itemArg is string => Boolean(itemArg));
|
||||
|
||||
const numberValue = (valueArg: unknown): number | undefined => {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg.match(/[-+]?\d+(?:\.\d+)?/)?.[0]);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const numberValues = (valueArg: unknown): number[] => values(valueArg).map((itemArg) => numberValue(itemArg)).filter((itemArg): itemArg is number => itemArg !== undefined);
|
||||
|
||||
const booleanValue = (valueArg: unknown): boolean | undefined => {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
const normalized = valueArg.trim().toLowerCase();
|
||||
if (['true', 'yes', 'on', '1'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (['false', 'no', 'off', '0'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const dateString = (valueArg: unknown): string | undefined => {
|
||||
if (typeof valueArg === 'string') {
|
||||
const parsed = Date.parse(valueArg);
|
||||
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const printerStateValue = (valueArg: unknown): TIppPrinterState => {
|
||||
if (typeof valueArg === 'number') {
|
||||
return valueArg === 3 ? 'idle' : valueArg === 4 ? 'printing' : valueArg === 5 ? 'stopped' : 'unknown';
|
||||
}
|
||||
const value = stringValue(valueArg)?.toLowerCase();
|
||||
return value === 'processing' ? 'printing' : value === 'idle' || value === 'printing' || value === 'stopped' ? value : value || 'unknown';
|
||||
};
|
||||
|
||||
const jobStateValue = (valueArg: unknown): string | undefined => {
|
||||
if (typeof valueArg === 'number') {
|
||||
return ({ 3: 'pending', 4: 'pending-held', 5: 'processing', 6: 'processing-stopped', 7: 'canceled', 8: 'aborted', 9: 'completed' } as Record<number, string>)[valueArg] || String(valueArg);
|
||||
}
|
||||
return stringValue(valueArg);
|
||||
};
|
||||
|
||||
const parseDeviceId = (valueArg: string | undefined): Record<string, string> => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const part of (valueArg || '').split(';')) {
|
||||
const separator = part.indexOf(':');
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = part.slice(0, separator).trim().toUpperCase().replace(/[\s-]+/g, '');
|
||||
const value = part.slice(separator + 1).trim();
|
||||
if (key && value) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const splitList = (valueArg: string | undefined): string[] => (valueArg || '').split(/[,;]/).map((partArg) => partArg.trim()).filter(Boolean);
|
||||
|
||||
const manufacturerFromMakeAndModel = (valueArg: string | undefined): string | undefined => {
|
||||
const firstWord = valueArg?.trim().split(/\s+/)[0];
|
||||
return firstWord && firstWord.length > 1 ? firstWord : undefined;
|
||||
};
|
||||
|
||||
const markerKind = (typeArg: string | undefined, nameArg: string | undefined): TIppMarkerKind => {
|
||||
const haystack = `${typeArg || ''} ${nameArg || ''}`.toLowerCase();
|
||||
if (haystack.includes('toner')) {
|
||||
return 'toner';
|
||||
}
|
||||
if (haystack.includes('ink')) {
|
||||
return 'ink';
|
||||
}
|
||||
if (haystack.includes('drum') || haystack.includes('opc')) {
|
||||
return 'drum';
|
||||
}
|
||||
if (haystack.includes('waste')) {
|
||||
return 'waste';
|
||||
}
|
||||
return 'marker';
|
||||
};
|
||||
|
||||
const markerName = (typeArg: string | undefined, colorArg: string | undefined, indexArg: number): string => {
|
||||
const parts = [colorArg, typeArg].filter(Boolean);
|
||||
return parts.length ? parts.join(' ') : `Marker ${indexArg + 1}`;
|
||||
};
|
||||
|
||||
const normalizeBasePath = (valueArg: string | undefined): string => {
|
||||
const value = (valueArg || ippDefaultBasePath).trim() || ippDefaultBasePath;
|
||||
return value.startsWith('/') ? value : `/${value}`;
|
||||
};
|
||||
|
||||
const parsedHost = (valueArg: string | undefined): { host: string; port?: number; basePath?: string; tls?: boolean } | undefined => {
|
||||
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(valueArg.replace(/^ipps:/i, 'https:').replace(/^ipp:/i, 'http:'));
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: url.port ? Number(url.port) : undefined,
|
||||
basePath: url.pathname && url.pathname !== '/' ? url.pathname : undefined,
|
||||
tls: url.protocol === 'https:',
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const formatHost = (valueArg: string): string => valueArg.includes(':') && !valueArg.startsWith('[') ? `[${valueArg}]` : valueArg;
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IIppAttributeRecord, IIppConfig, IIppSnapshot } from './ipp.types.js';
|
||||
import { ippDefaultBasePath, ippDefaultPort, ippDefaultTimeoutMs } from './ipp.types.js';
|
||||
|
||||
export class IppConfigFlow implements IConfigFlow<IIppConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IIppConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect IPP printer',
|
||||
description: 'Configure the local IPP printer endpoint.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||
{ name: 'port', label: 'Port', type: 'number' },
|
||||
{ name: 'basePath', label: 'Relative path to the printer', type: 'text' },
|
||||
{ name: 'tls', label: 'Use SSL/TLS', type: 'boolean' },
|
||||
{ name: 'verifySsl', label: 'Verify SSL certificate', type: 'boolean' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = snapshotFromMetadata(metadata);
|
||||
const attributes = attributesFromMetadata(metadata);
|
||||
const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.printer.host || '';
|
||||
if (!host && !snapshot && !attributes && !metadata.client) {
|
||||
return { kind: 'error', title: 'IPP setup failed', error: 'IPP setup requires a host, snapshot, attributes, or injected client.' };
|
||||
}
|
||||
const basePath = this.basePathValue(valuesArg.basePath) || this.stringMetadata(metadata, 'basePath') || snapshot?.printer.basePath || ippDefaultBasePath;
|
||||
const port = this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.printer.port || ippDefaultPort;
|
||||
const tls = this.booleanValue(valuesArg.tls) ?? this.booleanMetadata(metadata, 'tls') ?? snapshot?.printer.tls ?? false;
|
||||
const verifySsl = this.booleanValue(valuesArg.verifySsl) ?? this.booleanMetadata(metadata, 'verifySsl') ?? false;
|
||||
const uuid = this.stringMetadata(metadata, 'uuid') || snapshot?.printer.uuid;
|
||||
const serial = candidateArg.serialNumber || snapshot?.printer.serialNumber;
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'IPP printer configured',
|
||||
config: {
|
||||
host,
|
||||
port,
|
||||
basePath,
|
||||
tls,
|
||||
verifySsl,
|
||||
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.printer.name,
|
||||
manufacturer: candidateArg.manufacturer || snapshot?.printer.manufacturer,
|
||||
model: candidateArg.model || snapshot?.printer.model,
|
||||
uniqueId: candidateArg.id || uuid || serial || (host ? `${host}:${port}${basePath}` : undefined),
|
||||
uuid,
|
||||
serial,
|
||||
timeoutMs: ippDefaultTimeoutMs,
|
||||
snapshot,
|
||||
attributes,
|
||||
client: metadata.client as IIppConfig['client'],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) {
|
||||
return Math.round(valueArg);
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private booleanValue(valueArg: unknown): boolean | undefined {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
const normalized = valueArg.trim().toLowerCase();
|
||||
if (['true', 'yes', 'on', '1'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (['false', 'no', 'off', '0'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private basePathValue(valueArg: unknown): string | undefined {
|
||||
const value = this.stringValue(valueArg);
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return value.startsWith('/') ? value : `/${value}`;
|
||||
}
|
||||
|
||||
private stringMetadata(metadataArg: Record<string, unknown>, keyArg: string): string | undefined {
|
||||
return this.stringValue(metadataArg[keyArg]);
|
||||
}
|
||||
|
||||
private booleanMetadata(metadataArg: Record<string, unknown>, keyArg: string): boolean | undefined {
|
||||
const value = metadataArg[keyArg];
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const snapshotFromMetadata = (metadataArg: Record<string, unknown>): IIppSnapshot | undefined => {
|
||||
const snapshot = metadataArg.snapshot;
|
||||
return snapshot && typeof snapshot === 'object' && 'printer' in snapshot && 'status' in snapshot ? snapshot as IIppSnapshot : undefined;
|
||||
};
|
||||
|
||||
const attributesFromMetadata = (metadataArg: Record<string, unknown>): IIppAttributeRecord | undefined => {
|
||||
const attributes = metadataArg.attributes;
|
||||
return attributes && typeof attributes === 'object' && !Array.isArray(attributes) ? attributes as IIppAttributeRecord : undefined;
|
||||
};
|
||||
@@ -1,26 +1,83 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { IppClient } from './ipp.classes.client.js';
|
||||
import { IppConfigFlow } from './ipp.classes.configflow.js';
|
||||
import { createIppDiscoveryDescriptor } from './ipp.discovery.js';
|
||||
import { IppMapper } from './ipp.mapper.js';
|
||||
import type { IIppConfig } from './ipp.types.js';
|
||||
|
||||
export class HomeAssistantIppIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "ipp",
|
||||
displayName: "Internet Printing Protocol (IPP)",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/ipp",
|
||||
"upstreamDomain": "ipp",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"pyipp==0.17.0"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@ctalkington"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class IppIntegration extends BaseIntegration<IIppConfig> {
|
||||
public readonly domain = 'ipp';
|
||||
public readonly displayName = 'Internet Printing Protocol (IPP)';
|
||||
public readonly status = 'read-only-runtime' as const;
|
||||
public readonly discoveryDescriptor = createIppDiscoveryDescriptor();
|
||||
public readonly configFlow = new IppConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/ipp',
|
||||
upstreamDomain: 'ipp',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['pyipp==0.17.0'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@ctalkington'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/ipp',
|
||||
zeroconf: ['_ipps._tcp.local.', '_ipp._tcp.local.'],
|
||||
runtime: {
|
||||
type: 'read-only-runtime',
|
||||
polling: 'local HTTP(S) IPP Get-Printer-Attributes request',
|
||||
services: ['snapshot', 'status', 'refresh'],
|
||||
controls: false,
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IIppConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new IppRuntime(new IppClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantIppIntegration extends IppIntegration {}
|
||||
|
||||
class IppRuntime implements IIntegrationRuntime {
|
||||
public domain = 'ipp';
|
||||
|
||||
constructor(private readonly client: IppClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return IppMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return IppMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain !== 'ipp') {
|
||||
return { success: false, error: `Unsupported IPP service domain: ${requestArg.domain}` };
|
||||
}
|
||||
if (requestArg.service === 'snapshot' || requestArg.service === 'status') {
|
||||
return { success: true, data: await this.client.getSnapshot() };
|
||||
}
|
||||
if (requestArg.service === 'refresh') {
|
||||
const snapshot = await this.client.refresh();
|
||||
return snapshot.source !== 'runtime' || snapshot.online
|
||||
? { success: true, data: snapshot }
|
||||
: { success: false, error: snapshot.error || 'IPP refresh requires a host, snapshot, attributes, or client.', data: snapshot };
|
||||
}
|
||||
return { success: false, error: `Unsupported IPP service: ${requestArg.service}` };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { IIppAttributeRecord, IIppManualEntry, IIppMdnsRecord, IIppSnapshot } from './ipp.types.js';
|
||||
import { ippDefaultBasePath, ippDefaultPort } from './ipp.types.js';
|
||||
|
||||
const ippMdnsTypes = ['_ipp._tcp.local', '_ipps._tcp.local'];
|
||||
|
||||
export class IppMdnsMatcher implements IDiscoveryMatcher<IIppMdnsRecord> {
|
||||
public id = 'ipp-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize IPP and IPPS printer mDNS advertisements.';
|
||||
|
||||
public async matches(recordArg: IIppMdnsRecord): Promise<IDiscoveryMatch> {
|
||||
const type = normalizeType(recordArg.type);
|
||||
const properties = { ...recordArg.txt, ...recordArg.properties };
|
||||
const matched = ippMdnsTypes.includes(type);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record is not an IPP printer advertisement.' };
|
||||
}
|
||||
|
||||
const tls = type === '_ipps._tcp.local';
|
||||
const uuid = valueForKey(properties, 'UUID') || valueForKey(properties, 'uuid');
|
||||
const rp = valueForKey(properties, 'rp') || ippDefaultBasePath;
|
||||
const name = cleanName(recordArg.name || recordArg.hostname, type);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: uuid ? 'certain' : 'high',
|
||||
reason: 'mDNS record matches IPP printer service metadata.',
|
||||
normalizedDeviceId: uuid || recordArg.host || recordArg.addresses?.[0],
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: 'ipp',
|
||||
id: uuid,
|
||||
host: recordArg.host || recordArg.addresses?.[0],
|
||||
port: recordArg.port || ippDefaultPort,
|
||||
name: name || valueForKey(properties, 'ty'),
|
||||
manufacturer: valueForKey(properties, 'usb_MFG'),
|
||||
model: valueForKey(properties, 'usb_MDL') || valueForKey(properties, 'ty') || valueForKey(properties, 'product'),
|
||||
metadata: {
|
||||
ipp: true,
|
||||
mdnsName: recordArg.name,
|
||||
mdnsType: recordArg.type,
|
||||
txt: properties,
|
||||
tls,
|
||||
verifySsl: false,
|
||||
basePath: normalizeBasePath(rp),
|
||||
uuid,
|
||||
rp,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class IppManualMatcher implements IDiscoveryMatcher<IIppManualEntry> {
|
||||
public id = 'ipp-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual IPP printer setup entries.';
|
||||
|
||||
public async matches(inputArg: IIppManualEntry): Promise<IDiscoveryMatch> {
|
||||
const parsed = parseHost(inputArg.host);
|
||||
const metadata = inputArg.metadata || {};
|
||||
const snapshot = inputArg.snapshot || snapshotFromMetadata(metadata);
|
||||
const attributes = inputArg.attributes || attributesFromMetadata(metadata);
|
||||
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase();
|
||||
const matched = Boolean(inputArg.host || inputArg.port === ippDefaultPort || inputArg.basePath || metadata.ipp || snapshot || attributes || haystack.includes('ipp') || haystack.includes('printer'));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain IPP setup hints.' };
|
||||
}
|
||||
|
||||
const port = inputArg.port || parsed?.port || ippDefaultPort;
|
||||
const tls = inputArg.tls ?? inputArg.ssl ?? parsed?.tls ?? false;
|
||||
const basePath = normalizeBasePath(inputArg.basePath || parsed?.basePath || stringMetadata(metadata, 'basePath') || ippDefaultBasePath);
|
||||
const id = inputArg.id || inputArg.uuid || inputArg.serialNumber || snapshot?.printer.uuid || snapshot?.printer.serialNumber || (inputArg.host ? `${parsed?.host || inputArg.host}:${port}${basePath}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host || snapshot || attributes ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start IPP printer setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: 'ipp',
|
||||
id,
|
||||
host: parsed?.host || inputArg.host,
|
||||
port,
|
||||
name: inputArg.name || snapshot?.printer.name,
|
||||
manufacturer: inputArg.manufacturer || snapshot?.printer.manufacturer,
|
||||
model: inputArg.model || snapshot?.printer.model,
|
||||
serialNumber: inputArg.serialNumber || snapshot?.printer.serialNumber,
|
||||
metadata: {
|
||||
...metadata,
|
||||
ipp: true,
|
||||
tls,
|
||||
verifySsl: inputArg.verifySsl ?? booleanMetadata(metadata, 'verifySsl') ?? false,
|
||||
basePath,
|
||||
uuid: inputArg.uuid || snapshot?.printer.uuid,
|
||||
snapshot,
|
||||
attributes,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class IppCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'ipp-candidate-validator';
|
||||
public description = 'Validate IPP candidates have printer metadata and a usable host, snapshot, attributes, or client.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const type = normalizeType(stringMetadata(metadata, 'mdnsType'));
|
||||
const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase();
|
||||
const matched = candidateArg.integrationDomain === 'ipp'
|
||||
|| candidateArg.port === ippDefaultPort
|
||||
|| Boolean(metadata.ipp)
|
||||
|| ippMdnsTypes.includes(type)
|
||||
|| haystack.includes('ipp')
|
||||
|| haystack.includes('printer');
|
||||
const hasUsableSource = Boolean(candidateArg.host || metadata.snapshot || metadata.attributes || metadata.client);
|
||||
|
||||
if (!matched || !hasUsableSource) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'IPP candidate lacks host, snapshot, attributes, or client information.' : 'Candidate is not IPP.',
|
||||
};
|
||||
}
|
||||
|
||||
const basePath = normalizeBasePath(stringMetadata(metadata, 'basePath') || ippDefaultBasePath);
|
||||
const normalizedDeviceId = candidateArg.id || stringMetadata(metadata, 'uuid') || candidateArg.serialNumber || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || ippDefaultPort}${basePath}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: candidateArg.id || stringMetadata(metadata, 'uuid') || candidateArg.serialNumber ? 'certain' : 'high',
|
||||
reason: 'Candidate has IPP metadata and a usable source.',
|
||||
normalizedDeviceId,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
integrationDomain: 'ipp',
|
||||
port: candidateArg.port || ippDefaultPort,
|
||||
metadata: {
|
||||
...metadata,
|
||||
ipp: true,
|
||||
basePath,
|
||||
tls: booleanMetadata(metadata, 'tls') ?? false,
|
||||
verifySsl: booleanMetadata(metadata, 'verifySsl') ?? false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createIppDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: 'ipp', displayName: 'Internet Printing Protocol (IPP)' })
|
||||
.addMatcher(new IppMdnsMatcher())
|
||||
.addMatcher(new IppManualMatcher())
|
||||
.addValidator(new IppCandidateValidator());
|
||||
};
|
||||
|
||||
const normalizeType = (valueArg?: string): string => (valueArg || '').toLowerCase().replace(/\.$/, '');
|
||||
|
||||
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
|
||||
if (!recordArg) {
|
||||
return undefined;
|
||||
}
|
||||
const lowerKey = keyArg.toLowerCase();
|
||||
for (const [key, value] of Object.entries(recordArg)) {
|
||||
if (key.toLowerCase() === lowerKey) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const cleanName = (valueArg: string | undefined, typeArg: string): string => {
|
||||
const escaped = typeArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return valueArg?.replace(new RegExp(`\\.${escaped}\\.?$`, 'i'), '').replace(/\.local\.?$/i, '').trim() || '';
|
||||
};
|
||||
|
||||
const normalizeBasePath = (valueArg: string | undefined): string => {
|
||||
const value = (valueArg || ippDefaultBasePath).trim() || ippDefaultBasePath;
|
||||
return value.startsWith('/') ? value : `/${value}`;
|
||||
};
|
||||
|
||||
const parseHost = (valueArg: string | undefined): { host: string; port?: number; basePath?: string; tls?: boolean } | undefined => {
|
||||
if (!valueArg || !/^[a-z][a-z0-9+.-]*:\/\//i.test(valueArg)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(valueArg.replace(/^ipps:/i, 'https:').replace(/^ipp:/i, 'http:'));
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: url.port ? Number(url.port) : undefined,
|
||||
basePath: url.pathname && url.pathname !== '/' ? url.pathname : undefined,
|
||||
tls: url.protocol === 'https:',
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const stringMetadata = (metadataArg: Record<string, unknown>, keyArg: string): string | undefined => {
|
||||
const value = metadataArg[keyArg];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
};
|
||||
|
||||
const booleanMetadata = (metadataArg: Record<string, unknown>, keyArg: string): boolean | undefined => {
|
||||
const value = metadataArg[keyArg];
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
};
|
||||
|
||||
const snapshotFromMetadata = (metadataArg: Record<string, unknown>): IIppSnapshot | undefined => {
|
||||
const snapshot = metadataArg.snapshot;
|
||||
return snapshot && typeof snapshot === 'object' && 'printer' in snapshot && 'status' in snapshot ? snapshot as IIppSnapshot : undefined;
|
||||
};
|
||||
|
||||
const attributesFromMetadata = (metadataArg: Record<string, unknown>): IIppAttributeRecord | undefined => {
|
||||
const attributes = metadataArg.attributes;
|
||||
return attributes && typeof attributes === 'object' && !Array.isArray(attributes) ? attributes as IIppAttributeRecord : undefined;
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import type { IIppJobInfo, IIppMarkerInfo, IIppSnapshot } from './ipp.types.js';
|
||||
|
||||
const ippDomain = 'ipp';
|
||||
|
||||
export class IppMapper {
|
||||
public static toDevices(snapshotArg: IIppSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'status', capability: 'sensor', name: 'Printer status', readable: true, writable: false },
|
||||
{ id: 'queued_jobs', capability: 'sensor', name: 'Queued jobs', readable: true, writable: false },
|
||||
{ id: 'accepting_jobs', capability: 'sensor', name: 'Accepting jobs', readable: true, writable: false },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'status', value: snapshotArg.status.printerState, updatedAt },
|
||||
{ featureId: 'queued_jobs', value: snapshotArg.status.queuedJobCount ?? null, updatedAt },
|
||||
{ featureId: 'accepting_jobs', value: snapshotArg.status.acceptingJobs ?? null, updatedAt },
|
||||
];
|
||||
|
||||
for (const marker of snapshotArg.markers) {
|
||||
const featureId = `marker_${marker.index}`;
|
||||
features.push({ id: featureId, capability: 'sensor', name: marker.name, readable: true, writable: false, unit: '%' });
|
||||
state.push({ featureId, value: marker.level ?? null, updatedAt });
|
||||
}
|
||||
|
||||
for (const job of snapshotArg.jobs) {
|
||||
const featureId = `job_${this.slug(job.id)}`;
|
||||
features.push({ id: featureId, capability: 'sensor', name: job.name || `Job ${job.id}`, readable: true, writable: false });
|
||||
state.push({ featureId, value: job.state || null, updatedAt });
|
||||
}
|
||||
|
||||
return [{
|
||||
id: this.printerDeviceId(snapshotArg),
|
||||
integrationDomain: ippDomain,
|
||||
name: this.printerName(snapshotArg),
|
||||
protocol: 'http',
|
||||
manufacturer: snapshotArg.printer.manufacturer || 'Unknown',
|
||||
model: snapshotArg.printer.model || snapshotArg.printer.makeAndModel || 'IPP printer',
|
||||
online: snapshotArg.online,
|
||||
features,
|
||||
state,
|
||||
metadata: this.cleanAttributes({
|
||||
serialNumber: snapshotArg.printer.serialNumber,
|
||||
uuid: snapshotArg.printer.uuid,
|
||||
location: snapshotArg.printer.location,
|
||||
info: snapshotArg.printer.info,
|
||||
moreInfo: snapshotArg.printer.moreInfo,
|
||||
commandSet: snapshotArg.printer.commandSet,
|
||||
uriSupported: snapshotArg.printer.uriSupported,
|
||||
host: snapshotArg.printer.host,
|
||||
port: snapshotArg.printer.port,
|
||||
basePath: snapshotArg.printer.basePath,
|
||||
tls: snapshotArg.printer.tls,
|
||||
source: snapshotArg.source,
|
||||
stateReasons: snapshotArg.status.stateReasons,
|
||||
markerCount: snapshotArg.markers.length,
|
||||
jobCount: snapshotArg.jobs.length,
|
||||
error: snapshotArg.error,
|
||||
}),
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IIppSnapshot): IIntegrationEntity[] {
|
||||
const deviceId = this.printerDeviceId(snapshotArg);
|
||||
const uniqueBase = this.uniqueBase(snapshotArg);
|
||||
const baseName = this.printerName(snapshotArg);
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
|
||||
entities.push(this.entity('sensor', 'printer', `${baseName} Status`, snapshotArg.status.printerState, deviceId, uniqueBase, snapshotArg.online, {
|
||||
deviceClass: 'enum',
|
||||
options: ['idle', 'printing', 'stopped'],
|
||||
info: snapshotArg.printer.info,
|
||||
serial: snapshotArg.printer.serialNumber,
|
||||
uuid: snapshotArg.printer.uuid,
|
||||
location: snapshotArg.printer.location,
|
||||
stateMessage: snapshotArg.status.stateMessage,
|
||||
stateReason: snapshotArg.status.stateReasons,
|
||||
commandSet: snapshotArg.printer.commandSet,
|
||||
uriSupported: snapshotArg.printer.uriSupported?.join(','),
|
||||
manufacturer: snapshotArg.printer.manufacturer,
|
||||
model: snapshotArg.printer.model,
|
||||
source: snapshotArg.source,
|
||||
error: snapshotArg.error,
|
||||
}));
|
||||
|
||||
if (snapshotArg.status.bootedAt) {
|
||||
entities.push(this.entity('sensor', 'uptime', `${baseName} Uptime`, snapshotArg.status.bootedAt, deviceId, uniqueBase, snapshotArg.online, {
|
||||
deviceClass: 'timestamp',
|
||||
entityCategory: 'diagnostic',
|
||||
}));
|
||||
}
|
||||
|
||||
if (snapshotArg.status.queuedJobCount !== undefined) {
|
||||
entities.push(this.entity('sensor', 'queued_jobs', `${baseName} Queued Jobs`, snapshotArg.status.queuedJobCount, deviceId, uniqueBase, snapshotArg.online, {
|
||||
stateClass: 'measurement',
|
||||
}));
|
||||
}
|
||||
|
||||
if (snapshotArg.status.acceptingJobs !== undefined) {
|
||||
entities.push(this.entity('binary_sensor', 'accepting_jobs', `${baseName} Accepting Jobs`, snapshotArg.status.acceptingJobs ? 'on' : 'off', deviceId, uniqueBase, snapshotArg.online, {
|
||||
deviceClass: 'running',
|
||||
}));
|
||||
}
|
||||
|
||||
for (const marker of snapshotArg.markers) {
|
||||
entities.push(this.markerEntity(marker, deviceId, uniqueBase, snapshotArg.online));
|
||||
}
|
||||
|
||||
for (const job of snapshotArg.jobs) {
|
||||
entities.push(this.jobEntity(job, deviceId, uniqueBase, baseName, snapshotArg.online));
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static printerDeviceId(snapshotArg: IIppSnapshot): string {
|
||||
return `ipp.printer.${this.uniqueBase(snapshotArg)}`;
|
||||
}
|
||||
|
||||
public static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'ipp';
|
||||
}
|
||||
|
||||
private static markerEntity(markerArg: IIppMarkerInfo, deviceIdArg: string, uniqueBaseArg: string, availableArg: boolean): IIntegrationEntity {
|
||||
return this.entity('sensor', `marker_${markerArg.index}`, markerArg.name, markerArg.level ?? null, deviceIdArg, uniqueBaseArg, availableArg, {
|
||||
unit: '%',
|
||||
stateClass: 'measurement',
|
||||
markerType: markerArg.type,
|
||||
markerKind: markerArg.kind,
|
||||
markerColor: markerArg.color,
|
||||
markerLowLevel: markerArg.lowLevel,
|
||||
markerHighLevel: markerArg.highLevel,
|
||||
});
|
||||
}
|
||||
|
||||
private static jobEntity(jobArg: IIppJobInfo, deviceIdArg: string, uniqueBaseArg: string, baseNameArg: string, availableArg: boolean): IIntegrationEntity {
|
||||
return this.entity('sensor', `job_${this.slug(jobArg.id)}`, jobArg.name || `${baseNameArg} Job ${jobArg.id}`, jobArg.state || 'unknown', deviceIdArg, uniqueBaseArg, availableArg, {
|
||||
jobId: jobArg.id,
|
||||
owner: jobArg.owner,
|
||||
impressionsCompleted: jobArg.impressionsCompleted,
|
||||
createdAt: jobArg.createdAt,
|
||||
processingAt: jobArg.processingAt,
|
||||
completedAt: jobArg.completedAt,
|
||||
...jobArg.attributes,
|
||||
});
|
||||
}
|
||||
|
||||
private static entity(platformArg: IIntegrationEntity['platform'], keyArg: string, nameArg: string, stateArg: unknown, deviceIdArg: string, uniqueBaseArg: string, availableArg: boolean, attributesArg: Record<string, unknown>): IIntegrationEntity {
|
||||
return {
|
||||
id: `${platformArg}.${this.slug(nameArg)}`,
|
||||
uniqueId: `ipp_${uniqueBaseArg}_${keyArg}`,
|
||||
integrationDomain: ippDomain,
|
||||
deviceId: deviceIdArg,
|
||||
platform: platformArg,
|
||||
name: nameArg,
|
||||
state: stateArg,
|
||||
attributes: this.cleanAttributes(attributesArg),
|
||||
available: availableArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static printerName(snapshotArg: IIppSnapshot): string {
|
||||
return snapshotArg.printer.name || snapshotArg.printer.model || snapshotArg.printer.host || 'IPP printer';
|
||||
}
|
||||
|
||||
private static uniqueBase(snapshotArg: IIppSnapshot): string {
|
||||
return this.slug(snapshotArg.printer.uuid || snapshotArg.printer.serialNumber || snapshotArg.printer.id || snapshotArg.printer.host || this.printerName(snapshotArg));
|
||||
}
|
||||
|
||||
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,143 @@
|
||||
export interface IHomeAssistantIppConfig {
|
||||
// TODO: replace with the TypeScript-native config for ipp.
|
||||
export const ippDefaultPort = 631;
|
||||
export const ippDefaultBasePath = '/ipp/print';
|
||||
export const ippDefaultTimeoutMs = 10000;
|
||||
|
||||
export type TIppSnapshotSource = 'ipp' | 'client' | 'snapshot' | 'manual' | 'runtime';
|
||||
export type TIppPrinterState = 'idle' | 'printing' | 'stopped' | 'unknown' | string;
|
||||
export type TIppMarkerKind = 'marker' | 'ink' | 'toner' | 'drum' | 'waste' | string;
|
||||
|
||||
export interface IIppConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
basePath?: string;
|
||||
tls?: boolean;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
timeoutMs?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
uniqueId?: string;
|
||||
uuid?: string;
|
||||
serial?: string;
|
||||
snapshot?: IIppSnapshot;
|
||||
attributes?: IIppAttributeRecord;
|
||||
client?: IIppClientLike;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantIppConfig extends IIppConfig {}
|
||||
|
||||
export interface IIppClientLike {
|
||||
printer?: () => Promise<IIppSnapshot | IIppAttributeRecord>;
|
||||
getSnapshot?: () => Promise<IIppSnapshot | IIppAttributeRecord>;
|
||||
}
|
||||
|
||||
export interface IIppAttributeRecord {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IIppPrinterInfo {
|
||||
id?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
uuid?: string;
|
||||
version?: string;
|
||||
location?: string;
|
||||
info?: string;
|
||||
moreInfo?: string;
|
||||
makeAndModel?: string;
|
||||
deviceId?: string;
|
||||
commandSet?: string[];
|
||||
uriSupported?: string[];
|
||||
host?: string;
|
||||
port?: number;
|
||||
basePath?: string;
|
||||
tls?: boolean;
|
||||
}
|
||||
|
||||
export interface IIppStatusInfo {
|
||||
printerState: TIppPrinterState;
|
||||
stateMessage?: string;
|
||||
stateReasons: string[];
|
||||
acceptingJobs?: boolean;
|
||||
queuedJobCount?: number;
|
||||
uptimeSeconds?: number;
|
||||
bootedAt?: string;
|
||||
currentTime?: string;
|
||||
}
|
||||
|
||||
export interface IIppMarkerInfo {
|
||||
id?: string;
|
||||
index: number;
|
||||
name: string;
|
||||
kind: TIppMarkerKind;
|
||||
type?: string;
|
||||
color?: string;
|
||||
level?: number | null;
|
||||
lowLevel?: number;
|
||||
highLevel?: number;
|
||||
}
|
||||
|
||||
export interface IIppJobInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
state?: string;
|
||||
owner?: string;
|
||||
impressionsCompleted?: number;
|
||||
createdAt?: string;
|
||||
processingAt?: string;
|
||||
completedAt?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IIppSnapshot {
|
||||
printer: IIppPrinterInfo;
|
||||
status: IIppStatusInfo;
|
||||
markers: IIppMarkerInfo[];
|
||||
jobs: IIppJobInfo[];
|
||||
attributes?: IIppAttributeRecord;
|
||||
online: boolean;
|
||||
updatedAt?: string;
|
||||
source?: TIppSnapshotSource;
|
||||
error?: string;
|
||||
rawStatusCode?: number;
|
||||
}
|
||||
|
||||
export interface IIppParsedResponse {
|
||||
version: string;
|
||||
statusCode: number;
|
||||
requestId: number;
|
||||
attributes: IIppAttributeRecord;
|
||||
}
|
||||
|
||||
export interface IIppMdnsRecord {
|
||||
type?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
addresses?: string[];
|
||||
hostname?: string;
|
||||
txt?: Record<string, string | undefined>;
|
||||
properties?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface IIppManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
basePath?: string;
|
||||
tls?: boolean;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
uuid?: string;
|
||||
snapshot?: IIppSnapshot;
|
||||
attributes?: IIppAttributeRecord;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user