Add native local infrastructure integrations

This commit is contained in:
2026-05-05 19:06:21 +00:00
parent cfab8c593e
commit a144ef687c
70 changed files with 11607 additions and 183 deletions
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
+4
View File
@@ -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';
+653
View File
@@ -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;
};
+80 -23
View File
@@ -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();
}
}
+219
View File
@@ -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;
};
+174
View File
@@ -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));
}
}
+141 -2
View File
@@ -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>;
}