diff --git a/backend/shared/model/north-connector.model.ts b/backend/shared/model/north-connector.model.ts index 343602d544..b732cb4e6f 100644 --- a/backend/shared/model/north-connector.model.ts +++ b/backend/shared/model/north-connector.model.ts @@ -6,7 +6,16 @@ import { SouthConnectorLightDTO } from './south-connector.model'; export const OIBUS_NORTH_CATEGORIES = ['debug', 'api', 'file'] as const; export type OIBusNorthCategory = (typeof OIBUS_NORTH_CATEGORIES)[number]; -export const OIBUS_NORTH_TYPES = ['azure-blob', 'aws-s3', 'console', 'file-writer', 'oianalytics', 'sftp', 'rest'] as const; +export const OIBUS_NORTH_TYPES = [ + 'azure-blob', + 'aws-s3', + 'console', + 'file-writer', + 'metroscope-lithium', + 'oianalytics', + 'sftp', + 'rest' +] as const; export type OIBusNorthType = (typeof OIBUS_NORTH_TYPES)[number]; export interface NorthType { diff --git a/backend/shared/model/north-settings.model.ts b/backend/shared/model/north-settings.model.ts index 7bbaa9dced..786094f673 100644 --- a/backend/shared/model/north-settings.model.ts +++ b/backend/shared/model/north-settings.model.ts @@ -78,6 +78,15 @@ export interface NorthFileWriterSettings { suffix: string | null; } +export interface NorthMetroscopeLithiumSettings { + endpoint: string; + apiKey: string | null; + sourceId: string; + group: string; + label: string | null; + timeout: number; +} + export interface NorthOIAnalyticsSettings { useOiaModule: boolean; timeout: number; @@ -118,6 +127,7 @@ export type NorthSettings = | NorthAzureBlobSettings | NorthConsoleSettings | NorthFileWriterSettings + | NorthMetroscopeLithiumSettings | NorthOIAnalyticsSettings | NorthRESTSettings | NorthSFTPSettings; diff --git a/backend/src/north/north-metroscope-lithium/manifest.ts b/backend/src/north/north-metroscope-lithium/manifest.ts new file mode 100644 index 0000000000..9e7419c468 --- /dev/null +++ b/backend/src/north/north-metroscope-lithium/manifest.ts @@ -0,0 +1,65 @@ +import { NorthConnectorManifest } from '../../../shared/model/north-connector.model'; + +const manifest: NorthConnectorManifest = { + id: 'metroscope-lithium', + category: 'api', + modes: { + files: false, + points: true + }, + settings: [ + { + key: 'endpoint', + type: 'OibText', + newRow: true, + translationKey: 'north.metroscope-lithium.endpoint', + defaultValue: 'https://lithium.metroscope.io/api/open/import', + displayInViewMode: true, + validators: [{ key: 'required' }] + }, + { + key: 'apiKey', + type: 'OibSecret', + translationKey: 'north.metroscope-lithium.api-key', + defaultValue: '', + displayInViewMode: false + }, + { + key: 'sourceId', + type: 'OibText', + translationKey: 'north.metroscope-lithium.source-id', + defaultValue: 'oibus', + displayInViewMode: true, + validators: [{ key: 'required' }], + class: 'col-6' + }, + { + key: 'group', + type: 'OibText', + translationKey: 'north.metroscope-lithium.group', + defaultValue: 'cycle', + displayInViewMode: true, + validators: [{ key: 'required' }], + class: 'col-6' + }, + { + key: 'label', + type: 'OibText', + translationKey: 'north.metroscope-lithium.label', + defaultValue: '', + displayInViewMode: true, + class: 'col-6' + }, + { + key: 'timeout', + type: 'OibNumber', + translationKey: 'north.metroscope-lithium.timeout', + defaultValue: 30, + unitLabel: 's', + validators: [{ key: 'required' }], + class: 'col-6' + } + ] +}; + +export default manifest; diff --git a/backend/src/north/north-metroscope-lithium/north-metroscope-lithium.spec.ts b/backend/src/north/north-metroscope-lithium/north-metroscope-lithium.spec.ts new file mode 100644 index 0000000000..726e61aa0d --- /dev/null +++ b/backend/src/north/north-metroscope-lithium/north-metroscope-lithium.spec.ts @@ -0,0 +1,516 @@ +jest.mock('node:fs/promises'); +jest.mock('../../service/utils'); +jest.mock('../../service/http-request.utils'); + +import fs from 'node:fs/promises'; +import NorthMetroscopeLithium from './north-metroscope-lithium'; +import pino from 'pino'; +import PinoLogger from '../../tests/__mocks__/service/logger/logger.mock'; +import EncryptionService from '../../service/encryption.service'; +import EncryptionServiceMock from '../../tests/__mocks__/service/encryption-service.mock'; +import NorthConnectorRepository from '../../repository/config/north-connector.repository'; +import NorthConnectorRepositoryMock from '../../tests/__mocks__/repository/config/north-connector-repository.mock'; +import ScanModeRepository from '../../repository/config/scan-mode.repository'; +import ScanModeRepositoryMock from '../../tests/__mocks__/repository/config/scan-mode-repository.mock'; +import { NorthConnectorEntity } from '../../model/north-connector.model'; +import { NorthMetroscopeLithiumSettings } from '../../../shared/model/north-settings.model'; +import { OIBusTimeValue } from '../../../shared/model/engine.model'; +import testData from '../../tests/utils/test-data'; +import { HTTPRequest } from '../../service/http-request.utils'; +import { createMockResponse } from '../../tests/__mocks__/undici.mock'; +import { OIBusError } from '../../model/engine.model'; +import { mockBaseFolders } from '../../tests/utils/test-utils'; + +const logger: pino.Logger = new PinoLogger(); +const encryptionService: EncryptionService = new EncryptionServiceMock('', ''); +const northConnectorRepository: NorthConnectorRepository = new NorthConnectorRepositoryMock(); +const scanModeRepository: ScanModeRepository = new ScanModeRepositoryMock(); + +let north: NorthMetroscopeLithium; +let configuration: NorthConnectorEntity; + +const endpoint = 'https://lithium.metroscope.io/api/open/import'; +const settings: NorthMetroscopeLithiumSettings = { + endpoint, + apiKey: 'test-api-key', + sourceId: 'oibus-test', + group: 'cycle', + label: 'test-label', + timeout: 30 +}; + +const timeValues: Array = [ + { + pointId: 'Power', + timestamp: '2025-07-30T14:24:37.000Z', + data: { value: '5000', quality: 'good' } + }, + { + pointId: 'chiller_power_chiller_101', + timestamp: '2025-07-30T14:24:37.000Z', + data: { value: '3000', quality: 'good' } + }, + { + pointId: 'Temperature', + timestamp: '2025-07-30T14:25:37.000Z', + data: { value: '25.5', quality: 'good' } + } +]; + +describe('NorthMetroscopeLithium', () => { + beforeEach(async () => { + jest.clearAllMocks(); + configuration = { + ...testData.north.list[0], + settings + }; + (northConnectorRepository.findNorthById as jest.Mock).mockReturnValue(configuration); + (scanModeRepository.findById as jest.Mock).mockImplementation(id => testData.scanMode.list.find(element => element.id === id)); + (HTTPRequest as jest.Mock).mockResolvedValue(createMockResponse(200)); + (fs.readFile as jest.Mock).mockReturnValue(JSON.stringify(timeValues)); + + north = new NorthMetroscopeLithium( + configuration, + encryptionService, + northConnectorRepository, + scanModeRepository, + logger, + mockBaseFolders(testData.north.list[0].id) + ); + await north.start(); + }); + + it('should be able to test the connection', async () => { + const expectedReqOptions = { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + APIKEY: 'test-api-key' + }, + body: JSON.stringify({ + sourceId: 'oibus-test', + snapshots: [] + }), + timeout: 30000 + }; + + await north.testConnection(); + + expect(HTTPRequest).toHaveBeenCalledWith(new URL(endpoint), expectedReqOptions); + }); + + it('should manage timeout error on test connection', async () => { + (HTTPRequest as jest.Mock).mockRejectedValueOnce(new Error('Timeout error')); + + await expect(north.testConnection()).rejects.toThrow(`Failed to reach Metroscope Lithium endpoint ${endpoint}; message: Timeout error`); + + expect(HTTPRequest).toHaveBeenCalled(); + }); + + it('should manage bad response on test connection', async () => { + (HTTPRequest as jest.Mock).mockResolvedValueOnce(createMockResponse(401, 'Unauthorized')); + + await expect(north.testConnection()).rejects.toThrow( + new OIBusError('HTTP request failed with status code 401 and message: "Unauthorized"', false) + ); + + expect(HTTPRequest).toHaveBeenCalled(); + }); + + it('should handle time values', async () => { + await north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 1234, + numberOfElement: 3, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }); + + const expectedPayload = { + sourceId: 'oibus-test', + snapshots: [ + { + date: '2025-07-30T14:24:37.000Z', + acquisitionStartDate: '2025-07-30T14:24:37.000Z', + acquisitionEndDate: '2025-07-30T14:24:37.000Z', + group: 'cycle', + label: 'test-label', + sensorValues: [ + { + sensorTag: 'Power', + mean: 5000 + }, + { + sensorTag: 'chiller_power_chiller_101', + mean: 3000 + } + ] + }, + { + date: '2025-07-30T14:25:37.000Z', + acquisitionStartDate: '2025-07-30T14:25:37.000Z', + acquisitionEndDate: '2025-07-30T14:25:37.000Z', + group: 'cycle', + label: 'test-label', + sensorValues: [ + { + sensorTag: 'Temperature', + mean: 25.5 + } + ] + } + ] + }; + + const expectedReqOptions = { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + APIKEY: 'test-api-key' + }, + body: JSON.stringify(expectedPayload), + timeout: 30000 + }; + + expect(HTTPRequest).toHaveBeenCalledWith(new URL(endpoint), expectedReqOptions); + }); + + it('should handle values with empty label', async () => { + configuration.settings.label = null; + await north.start(); // Reload config + + await north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 1234, + numberOfElement: 3, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }); + + expect(HTTPRequest).toHaveBeenCalledWith( + new URL(endpoint), + expect.objectContaining({ + body: expect.stringContaining('"label":""') + }) + ); + }); + + it('should skip non-numeric values', async () => { + const invalidValues: Array = [ + { + pointId: 'Power', + timestamp: '2025-07-30T14:24:37.000Z', + data: { value: 'not-a-number', quality: 'good' } + }, + { + pointId: 'Temperature', + timestamp: '2025-07-30T14:24:37.000Z', + data: { value: '25.5', quality: 'good' } + } + ]; + (fs.readFile as jest.Mock).mockReturnValue(JSON.stringify(invalidValues)); + + await north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 1234, + numberOfElement: 2, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }); + + const expectedPayload = { + sourceId: 'oibus-test', + snapshots: [ + { + date: '2025-07-30T14:24:37.000Z', + acquisitionStartDate: '2025-07-30T14:24:37.000Z', + acquisitionEndDate: '2025-07-30T14:24:37.000Z', + group: 'cycle', + label: 'test-label', + sensorValues: [ + { + sensorTag: 'Temperature', + mean: 25.5 + } + ] + } + ] + }; + + expect(HTTPRequest).toHaveBeenCalledWith( + new URL(endpoint), + expect.objectContaining({ + body: JSON.stringify(expectedPayload) + }) + ); + }); + + it('should handle empty values gracefully', async () => { + (fs.readFile as jest.Mock).mockReturnValue(JSON.stringify([])); + + await north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 0, + numberOfElement: 0, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }); + + // Should not make HTTP request for empty values + expect(HTTPRequest).not.toHaveBeenCalled(); + }); + + it('should validate and format timestamps to ISO 8601', async () => { + const valuesWithDifferentTimestamps: Array = [ + { + pointId: 'Power', + timestamp: '2025-07-30T14:24:37Z', // Missing milliseconds + data: { value: '5000', quality: 'good' } + }, + { + pointId: 'Temperature', + timestamp: '2025-07-30T14:24:37.123Z', // Already ISO 8601 + data: { value: '25.5', quality: 'good' } + } + ]; + (fs.readFile as jest.Mock).mockReturnValue(JSON.stringify(valuesWithDifferentTimestamps)); + + await north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 1234, + numberOfElement: 2, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }); + + // Should normalize timestamps to ISO 8601 format + const expectedPayload = { + sourceId: 'oibus-test', + snapshots: [ + { + date: '2025-07-30T14:24:37.000Z', // Normalized + acquisitionStartDate: '2025-07-30T14:24:37.000Z', + acquisitionEndDate: '2025-07-30T14:24:37.000Z', + group: 'cycle', + label: 'test-label', + sensorValues: [ + { + sensorTag: 'Power', + mean: 5000 + } + ] + }, + { + date: '2025-07-30T14:24:37.123Z', // Already valid + acquisitionStartDate: '2025-07-30T14:24:37.123Z', + acquisitionEndDate: '2025-07-30T14:24:37.123Z', + group: 'cycle', + label: 'test-label', + sensorValues: [ + { + sensorTag: 'Temperature', + mean: 25.5 + } + ] + } + ] + }; + + expect(HTTPRequest).toHaveBeenCalledWith( + new URL(endpoint), + expect.objectContaining({ + body: JSON.stringify(expectedPayload) + }) + ); + }); + + it('should reject invalid timestamps', async () => { + const valuesWithInvalidTimestamp: Array = [ + { + pointId: 'Power', + timestamp: 'invalid-timestamp', + data: { value: '5000', quality: 'good' } + } + ]; + (fs.readFile as jest.Mock).mockReturnValue(JSON.stringify(valuesWithInvalidTimestamp)); + + await expect( + north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 1234, + numberOfElement: 1, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }) + ).rejects.toThrow(new OIBusError('Invalid timestamp format: invalid-timestamp. Expected ISO 8601 format.', false)); + + expect(HTTPRequest).not.toHaveBeenCalled(); + }); + + it('should properly throw fetch error with time values', async () => { + (HTTPRequest as jest.Mock).mockRejectedValueOnce(new Error('Network error')); + + await expect( + north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 1234, + numberOfElement: 3, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }) + ).rejects.toThrow(new OIBusError(`Failed to reach Metroscope Lithium endpoint ${endpoint}; message: Network error`, true)); + + expect(HTTPRequest).toHaveBeenCalled(); + }); + + it('should properly throw error on bad response without retrying', async () => { + // 400 error should not be retried + (HTTPRequest as jest.Mock).mockResolvedValueOnce(createMockResponse(400, 'Bad Request')); + + await expect( + north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 1234, + numberOfElement: 3, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }) + ).rejects.toThrow(new OIBusError('HTTP request failed with status code 400 and message: "Bad Request"', false)); + + expect(HTTPRequest).toHaveBeenCalled(); + }); + + it('should properly throw error on bad response with retrying', async () => { + // 502 error should be retried + (HTTPRequest as jest.Mock).mockResolvedValueOnce(createMockResponse(502, 'Bad Gateway')); + + await expect( + north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 1234, + numberOfElement: 3, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }) + ).rejects.toThrow(new OIBusError('HTTP request failed with status code 502 and message: "Bad Gateway"', true)); + + expect(HTTPRequest).toHaveBeenCalled(); + }); + + it('should reject raw files', async () => { + await expect( + north.handleContent({ + contentFile: 'path/to/file/example.file', + contentSize: 1234, + numberOfElement: 1, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'raw', + source: 'south', + options: {} + }) + ).rejects.toThrow(new OIBusError('Metroscope Lithium connector only supports time values, not raw files', false)); + + expect(HTTPRequest).not.toHaveBeenCalled(); + }); + + describe('error message handling', () => { + class ErrorWithCode extends Error { + constructor( + message: string, + public readonly code: number + ) { + super(message); + } + } + + it('should properly get message from generic Error', async () => { + (HTTPRequest as jest.Mock).mockRejectedValue(new Error('generic error object')); + + await expect( + north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 1234, + numberOfElement: 3, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }) + ).rejects.toThrow(new OIBusError(`Failed to reach Metroscope Lithium endpoint ${endpoint}; message: generic error object`, true)); + }); + + it('should properly get message from non Errors', async () => { + (HTTPRequest as jest.Mock).mockRejectedValue({ some: 'data' }); + + await expect( + north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 1234, + numberOfElement: 3, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }) + ).rejects.toThrow(new OIBusError(`Failed to reach Metroscope Lithium endpoint ${endpoint}; {"some":"data"}`, true)); + }); + + it('should properly get message from Error with code', async () => { + const error = new ErrorWithCode('error with code', 1); + (HTTPRequest as jest.Mock).mockRejectedValue(error); + + await expect( + north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 1234, + numberOfElement: 3, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }) + ).rejects.toThrow(new OIBusError(`Failed to reach Metroscope Lithium endpoint ${endpoint}; message: error with code, code: 1`, true)); + }); + + it('should properly get message from AggregateError', async () => { + const error1 = new ErrorWithCode('error with code 1', 1); + const error2 = new ErrorWithCode('error with code 2', 2); + (HTTPRequest as jest.Mock).mockRejectedValue(new AggregateError([error1, error2])); + + await expect( + north.handleContent({ + contentFile: '/path/to/file/example-123.json', + contentSize: 1234, + numberOfElement: 3, + createdAt: '2020-02-02T02:02:02.222Z', + contentType: 'time-values', + source: 'south', + options: {} + }) + ).rejects.toThrow( + new OIBusError( + `Failed to reach Metroscope Lithium endpoint ${endpoint}; message: error with code 1, code: 1; message: error with code 2, code: 2`, + true + ) + ); + }); + }); +}); diff --git a/backend/src/north/north-metroscope-lithium/north-metroscope-lithium.ts b/backend/src/north/north-metroscope-lithium/north-metroscope-lithium.ts new file mode 100644 index 0000000000..620ffed9bc --- /dev/null +++ b/backend/src/north/north-metroscope-lithium/north-metroscope-lithium.ts @@ -0,0 +1,362 @@ +import * as fs from 'node:fs/promises'; + +import NorthConnector from '../north-connector'; +import pino from 'pino'; +import EncryptionService from '../../service/encryption.service'; +import { NorthMetroscopeLithiumSettings } from '../../../shared/model/north-settings.model'; +import { CacheMetadata, OIBusTimeValue } from '../../../shared/model/engine.model'; +import { NorthConnectorEntity } from '../../model/north-connector.model'; +import NorthConnectorRepository from '../../repository/config/north-connector.repository'; +import ScanModeRepository from '../../repository/config/scan-mode.repository'; +import { BaseFolders } from '../../model/types'; +import { OIBusError } from '../../model/engine.model'; +import { HTTPRequest, ReqResponse, retryableHttpStatusCodes } from '../../service/http-request.utils'; + +/** + * Interface for sensor values in the Metroscope Lithium API + */ +interface SensorValue { + sensorTag: string; + mean: number; + std?: number; +} + +/** + * Interface for snapshot in the Metroscope Lithium API + */ +interface Snapshot { + date: string; + acquisitionStartDate: string; + acquisitionEndDate: string; + group: string; + label: string; + sensorValues: Array; +} + +/** + * Interface for the Metroscope Lithium API payload + */ +interface MetroscopeLithiumPayload { + sourceId: string; + snapshots: Array; +} + +/** + * Class NorthMetroscopeLithium - send values to Metroscope Lithium API via PATCH request + */ +export default class NorthMetroscopeLithium extends NorthConnector { + constructor( + configuration: NorthConnectorEntity, + encryptionService: EncryptionService, + northConnectorRepository: NorthConnectorRepository, + scanModeRepository: ScanModeRepository, + logger: pino.Logger, + baseFolders: BaseFolders + ) { + super(configuration, encryptionService, northConnectorRepository, scanModeRepository, logger, baseFolders); + } + + async handleContent(cacheMetadata: CacheMetadata): Promise { + switch (cacheMetadata.contentType) { + case 'time-values': + return this.handleValues(JSON.parse(await fs.readFile(cacheMetadata.contentFile, { encoding: 'utf-8' })) as Array); + + case 'raw': + throw new OIBusError('Metroscope Lithium connector only supports time values, not raw files', false); + } + } + + /** + * Handle time values by sending them to Metroscope Lithium API + */ + async handleValues(values: Array): Promise { + if (values.length === 0) { + this.logger.warn('No values to send to Metroscope Lithium'); + return; + } + + // Group values by timestamp to create snapshots + const snapshotMap = new Map>(); + + for (const value of values) { + const iso8601Timestamp = this.ensureISO8601Format(value.timestamp); + const pointId = value.pointId; + const numericValue = this.parseNumericValue(value.data.value); + + if (numericValue === null) { + this.logger.warn(`Skipping non-numeric value for point ${pointId}: ${value.data.value}`); + continue; + } + + if (!snapshotMap.has(iso8601Timestamp)) { + snapshotMap.set(iso8601Timestamp, new Map()); + } + + snapshotMap.get(iso8601Timestamp)!.set(pointId, numericValue); + } + + // Convert grouped values to snapshots + const snapshots: Array = []; + for (const [iso8601Timestamp, sensorValues] of snapshotMap.entries()) { + const sensorValuesArray: Array = []; + + for (const [sensorTag, value] of sensorValues.entries()) { + sensorValuesArray.push({ + sensorTag, + mean: value + }); + } + + snapshots.push({ + date: iso8601Timestamp, + acquisitionStartDate: iso8601Timestamp, + acquisitionEndDate: iso8601Timestamp, + group: this.connector.settings.group, + label: this.connector.settings.label || '', + sensorValues: sensorValuesArray + }); + } + + const payload: MetroscopeLithiumPayload = { + sourceId: this.connector.settings.sourceId, + snapshots + }; + + await this.sendToMetroscope(payload); + } + + /** + * Send payload to Metroscope Lithium API + */ + private async sendToMetroscope(payload: MetroscopeLithiumPayload): Promise { + // Decrypt the API key since it's encrypted + const decryptedApiKey = await this.encryptionService.decryptText(this.connector.settings.apiKey); + + if (!decryptedApiKey || decryptedApiKey.trim() === '') { + throw new OIBusError('API key is required for Metroscope Lithium connector', false); + } + + const endpoint = new URL(this.connector.settings.endpoint); + + const headers: Record = { + 'Content-Type': 'application/json', + APIKEY: decryptedApiKey + }; + + // Debug logging - log the request details + this.logger.debug(`Sending PATCH request to Metroscope Lithium: + Endpoint: ${endpoint.toString()} + Headers: ${JSON.stringify({ ...headers, APIKEY: '[REDACTED]' }, null, 2)} + Payload size: ${payload.snapshots.length} snapshots + Payload preview: ${JSON.stringify( + { + sourceId: payload.sourceId, + snapshotCount: payload.snapshots.length, + firstSnapshot: payload.snapshots[0] || null, + lastSnapshot: payload.snapshots[payload.snapshots.length - 1] || null + }, + null, + 2 + )}`); + + // Optionally log full payload (warning: can be large!) + if (this.logger.level === 'trace') { + this.logger.trace(`Full payload being sent: ${JSON.stringify(payload, null, 2)}`); + } + + let response: ReqResponse; + try { + const startTime = Date.now(); + response = await HTTPRequest(endpoint, { + method: 'PATCH', + headers, + body: JSON.stringify(payload), + timeout: this.connector.settings.timeout * 1000 + }); + const duration = Date.now() - startTime; + + this.logger.debug(`PATCH request completed in ${duration}ms with status ${response.statusCode}`); + this.logger.debug(`PATCH response headers: ${JSON.stringify(response.headers, null, 2)}`); + } catch (error) { + const message = this.getMessageFromError(error); + this.logger.error(`PATCH request failed: ${message}`); + throw new OIBusError(`Failed to reach Metroscope Lithium endpoint ${endpoint}; ${message}`, true); + } + + // Always log response details for debugging + const responseText = await response.body.text(); + this.logger.info(`Metroscope PATCH Response: + Status: ${response.statusCode} + Content-Length: ${response.headers['content-length'] || 'unknown'} + Content-Type: ${response.headers['content-type'] || 'unknown'} + Response Body: ${responseText || '(empty)'}`); + + if (!response.ok) { + this.logger.error(`PATCH request failed with status ${response.statusCode}: ${responseText}`); + throw new OIBusError( + `HTTP request failed with status code ${response.statusCode} and message: ${responseText}`, + retryableHttpStatusCodes.includes(response.statusCode) + ); + } + + // Log successful response + this.logger.info(`Successfully sent ${payload.snapshots.length} snapshots to Metroscope Lithium`); + } + + override async testConnection(): Promise { + // Decrypt the API key since it's encrypted during test + const decryptedApiKey = await this.encryptionService.decryptText(this.connector.settings.apiKey); + + // Debug: Log the actual API key state + this.logger.info(`Debug API Key info: + - apiKey exists: ${!!this.connector.settings.apiKey} + - apiKey type: ${typeof this.connector.settings.apiKey} + - apiKey length (encrypted): ${this.connector.settings.apiKey?.length || 0} + - apiKey length (decrypted): ${decryptedApiKey?.length || 0} + - apiKey value (encrypted): "${this.connector.settings.apiKey}" (showing encrypted value for debugging) + - apiKey value (decrypted): "${decryptedApiKey}" (showing decrypted value for debugging)`); + + if (!decryptedApiKey || decryptedApiKey.trim() === '') { + throw new OIBusError('API key is required for Metroscope Lithium connector', false); + } + + // Enable debug logging for this test + this.logger.level = 'debug'; + + // For testing, we'll send an empty payload to verify the API key and endpoint are valid + const testPayload: MetroscopeLithiumPayload = { + sourceId: this.connector.settings.sourceId, + snapshots: [] + }; + + const endpoint = new URL(this.connector.settings.endpoint); + + const headers: Record = { + 'Content-Type': 'application/json', + APIKEY: decryptedApiKey + }; + + // Debug logging for test connection + this.logger.debug(`Testing connection to Metroscope Lithium: + Endpoint: ${endpoint.toString()} + Headers: ${JSON.stringify({ ...headers, APIKEY: '[REDACTED]' }, null, 2)} + Test payload: ${JSON.stringify(testPayload, null, 2)}`); + + let response: ReqResponse; + try { + const startTime = Date.now(); + response = await HTTPRequest(endpoint, { + method: 'PATCH', + headers, + body: JSON.stringify(testPayload), + timeout: this.connector.settings.timeout * 1000 + }); + const duration = Date.now() - startTime; + + this.logger.debug(`Test connection completed in ${duration}ms with status ${response.statusCode}`); + this.logger.debug(`Test response headers: ${JSON.stringify(response.headers, null, 2)}`); + } catch (error) { + const message = this.getMessageFromError(error); + this.logger.error(`Test connection failed: ${message}`); + throw new OIBusError(`Failed to reach Metroscope Lithium endpoint ${endpoint}; ${message}`, false); + } + + // Always log test response details for debugging + const responseText = await response.body.text(); + this.logger.info(`Metroscope Test Connection Response: + Status: ${response.statusCode} + Content-Length: ${response.headers['content-length'] || 'unknown'} + Content-Type: ${response.headers['content-type'] || 'unknown'} + Response Body: ${responseText || '(empty)'}`); + + if (!response.ok) { + this.logger.error(`Test connection failed with status ${response.statusCode}: ${responseText}`); + throw new OIBusError(`HTTP request failed with status code ${response.statusCode} and message: ${responseText}`, false); + } + + // Log successful test response + this.logger.info('Test connection to Metroscope Lithium successful'); + } + + /** + * Parse a value to number, returns null if not a valid number + */ + private parseNumericValue(value: unknown): number | null { + if (typeof value === 'number') { + return isNaN(value) ? null : value; + } + + if (typeof value === 'string') { + const parsed = parseFloat(value); + return isNaN(parsed) ? null : parsed; + } + + return null; + } + + /** + * Debug helper: Enable verbose HTTP logging for debugging API calls + * This will log detailed request/response information + */ + enableVerboseHttpLogging(): void { + this.logger.info('Verbose HTTP logging enabled for Metroscope Lithium connector'); + // You can set the logger level to 'debug' or 'trace' for more detailed logs + // this.logger.level = 'debug'; // Uncomment this line for debug logs + // this.logger.level = 'trace'; // Uncomment this line for trace logs (includes full payloads) + } + + /** + * Ensure timestamp is in ISO 8601 format + * @param timestamp - The timestamp to validate and format + * @returns ISO 8601 formatted timestamp + */ + private ensureISO8601Format(timestamp: string): string { + try { + // Try to parse the timestamp as a Date + const date = new Date(timestamp); + + // Check if the date is valid + if (isNaN(date.getTime())) { + throw new Error(`Invalid timestamp: ${timestamp}`); + } + + // Return ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ) + return date.toISOString(); + } catch (error) { + this.logger.error(`Failed to parse timestamp "${timestamp}": ${error}`); + throw new OIBusError(`Invalid timestamp format: ${timestamp}. Expected ISO 8601 format.`, false); + } + } + + /** + * Convert an unknown request error to a readable message + */ + private getMessageFromError(error: unknown) { + if (!(error instanceof Error)) { + return String(JSON.stringify(error)); + } + + const errors: Array = error instanceof AggregateError ? error.errors : [error]; + + const messages: Array = []; + + for (const err of errors) { + let code: string | number | undefined = undefined; + let message: string | undefined = undefined; + + if (err.message) { + message = `message: ${err.message}`; + } + + if ('code' in err && err.code && (typeof err.code === 'string' || typeof err.code === 'number')) { + code = `code: ${err.code}`; + } + + if ([message, code].filter(Boolean).length) { + messages.push([message, code].filter(Boolean).join(', ')); + } + } + + return messages.join('; '); + } +} diff --git a/backend/src/service/north.service.ts b/backend/src/service/north.service.ts index d9168509f2..6ae59bbae6 100644 --- a/backend/src/service/north.service.ts +++ b/backend/src/service/north.service.ts @@ -15,6 +15,7 @@ import consoleManifest from '../north/north-console/manifest'; import amazonManifest from '../north/north-amazon-s3/manifest'; import sftpManifest from '../north/north-sftp/manifest'; import restManifest from '../north/north-rest/manifest'; +import metroscopeLithiumManifest from '../north/north-metroscope-lithium/manifest'; import { NorthConnectorEntity, NorthConnectorEntityLight } from '../model/north-connector.model'; import JoiValidator from '../web-server/controllers/validators/joi.validator'; import NorthConnectorRepository from '../repository/config/north-connector.repository'; @@ -31,6 +32,7 @@ import { NorthAzureBlobSettings, NorthConsoleSettings, NorthFileWriterSettings, + NorthMetroscopeLithiumSettings, NorthOIAnalyticsSettings, NorthRESTSettings, NorthSettings, @@ -41,6 +43,7 @@ import OIAnalyticsRegistrationRepository from '../repository/config/oianalytics- import NorthAmazonS3 from '../north/north-amazon-s3/north-amazon-s3'; import NorthAzureBlob from '../north/north-azure-blob/north-azure-blob'; import NorthFileWriter from '../north/north-file-writer/north-file-writer'; +import NorthMetroscopeLithium from '../north/north-metroscope-lithium/north-metroscope-lithium'; import NorthOIAnalytics from '../north/north-oianalytics/north-oianalytics'; import NorthSFTP from '../north/north-sftp/north-sftp'; import NorthREST from '../north/north-rest/north-rest'; @@ -59,6 +62,7 @@ export const northManifestList: Array = [ azureManifest, amazonManifest, fileWriterManifest, + metroscopeLithiumManifest, sftpManifest, restManifest ]; @@ -142,6 +146,15 @@ export default class NorthService { logger, northBaseFolders ); + case 'metroscope-lithium': + return new NorthMetroscopeLithium( + settings as NorthConnectorEntity, + this.encryptionService, + this.northConnectorRepository, + this.scanModeRepository, + logger, + northBaseFolders + ); case 'rest': return new NorthREST( settings as NorthConnectorEntity, diff --git a/backend/src/settings-interface.generator.ts b/backend/src/settings-interface.generator.ts index 6a5539c5b6..ecc4277eb9 100644 --- a/backend/src/settings-interface.generator.ts +++ b/backend/src/settings-interface.generator.ts @@ -287,6 +287,8 @@ function buildNorthInterfaceName(connectorId: string): string { return 'NorthConsoleSettings'; case 'file-writer': return 'NorthFileWriterSettings'; + case 'metroscope-lithium': + return 'NorthMetroscopeLithiumSettings'; case 'oianalytics': return 'NorthOIAnalyticsSettings'; case 'rest': diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 6c9668502c..723e63214f 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -199,7 +199,8 @@ "file-writer": "File writer", "oianalytics": "OIAnalytics®", "sftp": "SFTP", - "rest": "REST" + "rest": "REST", + "metroscope-lithium": "Metroscope Lithium" }, "oibus-north-type-description": { "aws-s3": "Store files in Amazon S3™ (Simple Storage Service)", @@ -208,7 +209,8 @@ "file-writer": "Write files and data to the output folder", "oianalytics": "Send files and values to OIAnalytics®", "sftp": "Upload files and data to an SFTP server", - "rest": "Upload files to a REST endpoint" + "rest": "Upload files to a REST endpoint", + "metroscope-lithium": "Send data to a Metroscope Lithium endpoint" }, "oibus-south-category": { "iot": "IOT", @@ -1716,6 +1718,14 @@ "proxy-url": "Proxy URL", "proxy-username": "Proxy username", "proxy-password": "Proxy password" + }, + "metroscope-lithium": { + "endpoint": "Lithium endpoint", + "api-key": "API key", + "source-id": "Source ID", + "timeout": "Timeout", + "group": "Group", + "label": "Label" } }, "logs": {