import { ChangeDetectorRef, Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HttpBackend, HttpClient, HttpClientModule, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { FormsModule } from '@angular/forms'; import { defer } from 'rxjs'; import { finalize, tap } from 'rxjs/operators'; type StepStatus = 'idle' | 'loading' | 'success' | 'error'; export interface RdApiError { httpStatus: number; httpStatusText: string; errCode: string; errInfo: string; rawBody: string; message: string; } export interface RdServiceInfo { status: string; info: string; raw: string; } export interface DeviceInfo { dpId: string; rdsId: string; rdsVer: string; dc: string; mi: string; mc: string; error: string; additionalInfo: { [key: string]: string }; raw: string; } export interface PidData { errCode: string; errInfo: string; fCount: string; fType: string; nmPoints: string; qScore: string; skeyCi: string; skey: string; hmac: string; // Optional keeps existing PidData object construction source-compatible. dataType?: string; data: string; deviceInfo: DeviceInfo | null; raw: string; } @Component({ selector: 'app-l1-device', standalone: true, imports: [CommonModule, HttpClientModule, FormsModule], templateUrl: './l1-device.component.html', // Use the plural property for compatibility with older Angular versions. styleUrls: ['./l1-device.component.scss'] }) export class L1DeviceComponent { rdServiceStatus: StepStatus = 'idle'; deviceInfoStatus: StepStatus = 'idle'; captureStatus: StepStatus = 'idle'; rdServiceInfo: RdServiceInfo | null = null; deviceInfo: DeviceInfo | null = null; pidData: PidData | null = null; // Actual error detail per step: status code, raw body and RD error values. rdServiceError: RdApiError | null = null; deviceInfoError: RdApiError | null = null; captureError: RdApiError | null = null; txtData = ''; private readonly baseUrl = 'https://localhost:11200'; private readonly http: HttpClient; constructor( httpBackend: HttpBackend, private readonly changeDetector: ChangeDetectorRef ) { /* * This dedicated client bypasses the application's interceptor chain. * It prevents authentication, JSON content headers or other application * settings from being added to localhost RD Service requests. */ this.http = new HttpClient(httpBackend); } /** Runs RDSERVICE, DEVICEINFO and CAPTURE in order. */ runFullFlow(): void { this.clearErrors(); this.resetResults(); this.checkRdService(() => { this.getDeviceInfo(() => { this.captureFinger(); }); }); } clearErrors(): void { this.rdServiceError = null; this.deviceInfoError = null; this.captureError = null; } checkRdService(onSuccess?: () => void): void { this.rdServiceStatus = 'loading'; this.rdServiceError = null; this.getRdServiceInfo() .pipe(finalize(() => this.refreshView())) .subscribe({ next: (xml: string) => { try { this.rdServiceInfo = this.parseRdServiceInfo(xml); if (this.rdServiceInfo.status.toUpperCase() !== 'READY') { this.rdServiceStatus = 'error'; this.rdServiceError = { httpStatus: 200, httpStatusText: 'OK', errCode: '', errInfo: this.rdServiceInfo.info, rawBody: xml, message: `Device reported status "${this.rdServiceInfo.status || '(empty)'}"` + ' instead of READY.' }; return; } this.rdServiceStatus = 'success'; if (onSuccess) { onSuccess(); } } catch (error) { this.rdServiceStatus = 'error'; this.rdServiceError = this.parseError(error); console.error('Invalid RDSERVICE response', error); } }, error: (error: unknown) => { this.rdServiceStatus = 'error'; this.rdServiceError = this.parseError(error); console.error('RDSERVICE error', error); } }); } getDeviceInfo(onSuccess?: () => void): void { this.deviceInfoStatus = 'loading'; this.deviceInfoError = null; this.getDeviceInfoRequest() .pipe(finalize(() => this.refreshView())) .subscribe({ next: (xml: string) => { try { this.deviceInfo = this.parseDeviceInfo(xml); if (this.deviceInfo.error) { this.deviceInfoStatus = 'error'; this.deviceInfoError = { httpStatus: 200, httpStatusText: 'OK', errCode: '', errInfo: this.deviceInfo.error, rawBody: xml, message: `DeviceInfo returned with error attribute: ${this.deviceInfo.error}` }; return; } this.deviceInfoStatus = 'success'; if (onSuccess) { onSuccess(); } } catch (error) { this.deviceInfoStatus = 'error'; this.deviceInfoError = this.parseError(error); console.error('Invalid DEVICEINFO response', error); } }, error: (error: unknown) => { this.deviceInfoStatus = 'error'; this.deviceInfoError = this.parseError(error); console.error('DEVICEINFO error', error); } }); } captureFinger(): void { this.captureStatus = 'loading'; this.captureError = null; this.captureRequest() .pipe(finalize(() => this.refreshView())) .subscribe({ next: (xml: string) => { try { this.pidData = this.parsePidData(xml); if (this.pidData.errCode !== '0') { this.captureStatus = 'error'; this.captureError = { httpStatus: 200, httpStatusText: 'OK', errCode: this.pidData.errCode, errInfo: this.pidData.errInfo, rawBody: xml, message: `Capture returned error ${this.pidData.errCode}: ` + (this.pidData.errInfo || '(no errInfo provided)') }; return; } this.captureStatus = 'success'; } catch (error) { this.captureStatus = 'error'; this.captureError = this.parseError(error); console.error('Invalid CAPTURE response', error); } }, error: (error: unknown) => { this.captureStatus = 'error'; this.captureError = this.parseError(error); console.error('CAPTURE error', error); } }); } /* * These three public request methods retain the names used by the existing * component and return Observable, as before. */ getRdServiceInfo() { return this.http.request('RDSERVICE', this.baseUrl + '/', { responseType: 'text', withCredentials: false }).pipe( tap((xml: string) => this.parseXml(xml, 'RDSERVICE response')) ); } getDeviceInfoRequest() { return this.http.request('DEVICEINFO', this.baseUrl + '/rd/info', { responseType: 'text', withCredentials: false }).pipe( tap((xml: string) => this.parseXml(xml, 'DEVICEINFO response')) ); } captureRequest(pidOptionsXml?: string) { const body = pidOptionsXml || this.defaultPidOptions(); return defer(() => { // Validate locally before sending malformed PID options to the scanner. const pidOptionsDocument = this.parseXml(body, 'PidOptions request'); if ( !pidOptionsDocument.documentElement || pidOptionsDocument.documentElement.nodeName.toLowerCase() !== 'pidoptions' ) { throw new Error( 'PidOptions request must have a root element.' ); } if (pidOptionsDocument.getElementsByTagName('Opts').length === 0) { throw new Error('PidOptions request must contain an element.'); } return this.http.request('CAPTURE', this.baseUrl + '/rd/capture', { body, responseType: 'text', withCredentials: false, headers: new HttpHeaders({ 'Content-Type': 'text/xml' }) }).pipe( tap((xml: string) => this.parseXml(xml, 'CAPTURE response')) ); }); } private defaultPidOptions(): string { /* * This is intentionally identical to the compact XML used by the two * working ACPL CaptureDemo samples. */ return ( '' + '' + '' ); } parseRdServiceInfo(xml: string): RdServiceInfo { const doc = this.parseXml(xml, 'RDSERVICE response'); const element = doc.getElementsByTagName('RDService')[0]; if (!element) { throw new Error('RDSERVICE response does not contain .'); } return { status: element.getAttribute('status') || '', info: element.getAttribute('info') || '', raw: xml }; } parseDeviceInfo(xml: string): DeviceInfo { const doc = this.parseXml(xml, 'DEVICEINFO response'); const element = doc.getElementsByTagName('DeviceInfo')[0]; if (!element) { throw new Error('DEVICEINFO response does not contain .'); } const additionalInfo: { [key: string]: string } = {}; const infoNodes = doc.getElementsByTagName('Info'); const paramNodes = doc.getElementsByTagName('Param'); for (let i = 0; i < infoNodes.length; i++) { const name = infoNodes[i].getAttribute('name') || infoNodes[i].getAttribute('key'); const value = infoNodes[i].getAttribute('value') || (infoNodes[i].textContent || ''); if (name) { additionalInfo[name] = value; } } // Some RD Service builds use . for (let i = 0; i < paramNodes.length; i++) { const name = paramNodes[i].getAttribute('name'); const value = paramNodes[i].getAttribute('value') || (paramNodes[i].textContent || ''); if (name) { additionalInfo[name] = value; } } return { dpId: element.getAttribute('dpId') || '', rdsId: element.getAttribute('rdsId') || '', rdsVer: element.getAttribute('rdsVer') || '', dc: element.getAttribute('dc') || '', mi: element.getAttribute('mi') || '', mc: element.getAttribute('mc') || '', error: element.getAttribute('error') || '', additionalInfo, raw: xml }; } parseError(error: unknown): RdApiError { const httpError = error instanceof HttpErrorResponse ? error : null; const httpStatus = httpError ? httpError.status : 0; const httpStatusText = httpError ? httpError.statusText : ''; const rawBody = httpError && typeof httpError.error === 'string' ? httpError.error : error instanceof Error ? error.message : String(error || ''); let errCode = ''; let errInfo = ''; if (rawBody && rawBody.trim().startsWith('<')) { try { const doc = this.parseXml(rawBody, 'RD Service error response'); const errorElement = doc.getElementsByTagName('Error')[0]; const responseElement = doc.getElementsByTagName('Resp')[0]; const element = errorElement || responseElement || doc.documentElement; if (element) { errCode = element.getAttribute('errCode') || ''; errInfo = element.getAttribute('errInfo') || element.getAttribute('error') || ''; } } catch { // Preserve the original response even when the error body is not XML. } } let message: string; if (!httpError) { message = rawBody || 'Unknown RD Service error.'; } else if (httpStatus === 0) { message = 'No browser response from https://localhost:11200. Confirm the RD ' + 'Service is running, then inspect the browser Console and the OPTIONS ' + 'request in Network for CORS/preflight, certificate, Content Security ' + 'Policy, mixed-content, or local/loopback-network permission errors. ' + 'Open https://localhost:11200/ once in the same browser profile to ' + 'check whether its HTTPS certificate is accepted.'; } else if (errCode || errInfo) { message = `RD Service returned error ${errCode || '(no code)'}: ` + (errInfo || '(no error information)'); } else if (httpStatus === 400 || httpStatus === 415) { message = `HTTP ${httpStatus} ${httpStatusText}. The RD Service rejected the ` + 'request. CAPTURE is configured as text/xml with the same compact ' + 'PidOptions used by the working ACPL demos.'; } else if (httpStatus === 404 || httpStatus === 405) { message = `HTTP ${httpStatus} ${httpStatusText}. Verify the RD Service endpoint ` + 'and that OPTIONS, RDSERVICE, DEVICEINFO and CAPTURE methods are allowed.'; } else { message = `HTTP ${httpStatus} ${httpStatusText}`; } return { httpStatus, httpStatusText, errCode, errInfo, rawBody, message }; } parsePidData(xml: string): PidData { const doc = this.parseXml(xml, 'CAPTURE response'); const responseElement = doc.getElementsByTagName('Resp')[0]; const skeyElement = doc.getElementsByTagName('Skey')[0]; const hmacElement = doc.getElementsByTagName('Hmac')[0]; const dataElement = doc.getElementsByTagName('Data')[0]; const deviceInfoElement = doc.getElementsByTagName('DeviceInfo')[0]; if (!responseElement) { throw new Error('CAPTURE response does not contain .'); } let capturedDeviceInfo: DeviceInfo | null = null; if (deviceInfoElement) { capturedDeviceInfo = this.parseDeviceInfo( new XMLSerializer().serializeToString(deviceInfoElement) ); } return { errCode: responseElement.getAttribute('errCode') || '', errInfo: responseElement.getAttribute('errInfo') || '', fCount: responseElement.getAttribute('fCount') || '', fType: responseElement.getAttribute('fType') || '', nmPoints: responseElement.getAttribute('nmPoints') || '', qScore: responseElement.getAttribute('qScore') || '', skeyCi: skeyElement ? skeyElement.getAttribute('ci') || '' : '', skey: skeyElement ? skeyElement.textContent || '' : '', hmac: hmacElement ? hmacElement.textContent || '' : '', dataType: dataElement ? dataElement.getAttribute('type') || '' : '', data: dataElement ? dataElement.textContent || '' : '', deviceInfo: capturedDeviceInfo, raw: xml }; } private parseXml(xml: string, context: string): Document { if (!xml || !xml.trim()) { throw new Error(`${context} was empty.`); } const doc = new DOMParser().parseFromString(xml, 'text/xml'); const parserError = doc.getElementsByTagName('parsererror')[0]; if (parserError) { const detail = (parserError.textContent || '').trim(); throw new Error( `${context} is not valid XML.` + (detail ? ` ${detail}` : '') ); } return doc; } private resetResults(): void { this.rdServiceStatus = 'idle'; this.deviceInfoStatus = 'idle'; this.captureStatus = 'idle'; this.rdServiceInfo = null; this.deviceInfo = null; this.pidData = null; } /* * A HttpClient created directly from HttpBackend bypasses interceptors as * required, but zoneless Angular applications may not automatically repaint * after its asynchronous callbacks. Mark the component after each request * completes so the first click immediately displays the returned data. */ private refreshView(): void { this.changeDetector.markForCheck(); } }