Migrar backend a PostgreSQL + Node.js/Express con nuevas funcionalidades
Backend (water-api/): - Crear API REST completa con Express + TypeScript - Implementar autenticación JWT con refresh tokens - CRUD completo para: projects, concentrators, meters, gateways, devices, users, roles - Agregar validación con Zod para todas las entidades - Implementar webhooks para The Things Stack (LoRaWAN) - Agregar endpoint de lecturas con filtros y resumen de consumo - Implementar carga masiva de medidores via Excel (.xlsx) Frontend: - Crear cliente HTTP con manejo automático de JWT y refresh - Actualizar todas las APIs para usar nuevo backend - Agregar sistema de autenticación real (login, logout, me) - Agregar selector de tipo (LORA, LoRaWAN, Grandes) en concentradores y medidores - Agregar campo Meter ID en medidores - Crear modal de carga masiva para medidores - Agregar página de consumo con gráficas y filtros - Corregir carga de proyectos independiente de datos existentes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
518
water-api/src/services/tts/ttsApi.service.ts
Normal file
518
water-api/src/services/tts/ttsApi.service.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
import logger from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* TTS API configuration
|
||||
*/
|
||||
interface TtsApiConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
applicationId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device registration payload for TTS
|
||||
*/
|
||||
export interface TtsDeviceRegistration {
|
||||
devEui: string;
|
||||
joinEui: string;
|
||||
deviceId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
appKey: string;
|
||||
nwkKey?: string;
|
||||
lorawanVersion?: string;
|
||||
lorawanPhyVersion?: string;
|
||||
frequencyPlanId?: string;
|
||||
supportsClassC?: boolean;
|
||||
supportsJoin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downlink message payload
|
||||
*/
|
||||
export interface TtsDownlinkPayload {
|
||||
fPort: number;
|
||||
frmPayload: string; // Base64 encoded
|
||||
confirmed?: boolean;
|
||||
priority?: 'LOWEST' | 'LOW' | 'BELOW_NORMAL' | 'NORMAL' | 'ABOVE_NORMAL' | 'HIGH' | 'HIGHEST';
|
||||
classBC?: {
|
||||
absoluteTime?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Device status from TTS API
|
||||
*/
|
||||
export interface TtsDeviceStatus {
|
||||
devEui: string;
|
||||
deviceId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
lastSeenAt?: string;
|
||||
session?: {
|
||||
devAddr: string;
|
||||
startedAt: string;
|
||||
};
|
||||
macState?: {
|
||||
lastDevStatusReceivedAt?: string;
|
||||
batteryPercentage?: number;
|
||||
margin?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of TTS API operations
|
||||
*/
|
||||
export interface TtsApiResult<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get TTS API configuration from environment
|
||||
*/
|
||||
function getTtsConfig(): TtsApiConfig {
|
||||
return {
|
||||
apiUrl: process.env.TTS_API_URL || '',
|
||||
apiKey: process.env.TTS_API_KEY || '',
|
||||
applicationId: process.env.TTS_APPLICATION_ID || '',
|
||||
enabled: process.env.TTS_ENABLED === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if TTS integration is enabled
|
||||
*/
|
||||
export function isTtsEnabled(): boolean {
|
||||
const config = getTtsConfig();
|
||||
return config.enabled && !!config.apiUrl && !!config.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to the TTS API
|
||||
*/
|
||||
async function ttsApiRequest<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
body?: unknown
|
||||
): Promise<TtsApiResult<T>> {
|
||||
const config = getTtsConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
logger.debug('TTS API is disabled, skipping request', { path });
|
||||
return {
|
||||
success: false,
|
||||
error: 'TTS integration is disabled',
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.apiUrl || !config.apiKey) {
|
||||
logger.warn('TTS API configuration missing', {
|
||||
hasApiUrl: !!config.apiUrl,
|
||||
hasApiKey: !!config.apiKey,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: 'TTS API configuration is incomplete',
|
||||
};
|
||||
}
|
||||
|
||||
const url = `${config.apiUrl}${path}`;
|
||||
|
||||
try {
|
||||
logger.debug('Making TTS API request', { method, path });
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
let responseData: T | undefined;
|
||||
|
||||
try {
|
||||
responseData = responseText ? JSON.parse(responseText) : undefined;
|
||||
} catch {
|
||||
// Response is not JSON
|
||||
logger.debug('TTS API response is not JSON', { responseText: responseText.substring(0, 200) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('TTS API request failed', {
|
||||
method,
|
||||
path,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
response: responseText.substring(0, 500),
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `TTS API error: ${response.status} ${response.statusText}`,
|
||||
statusCode: response.status,
|
||||
data: responseData,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug('TTS API request successful', { method, path, status: response.status });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: responseData,
|
||||
statusCode: response.status,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('TTS API request error', {
|
||||
method,
|
||||
path,
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `TTS API request failed: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a device in The Things Stack
|
||||
*
|
||||
* @param device - Device registration details
|
||||
* @returns Result of the registration
|
||||
*/
|
||||
export async function registerDevice(
|
||||
device: TtsDeviceRegistration
|
||||
): Promise<TtsApiResult<TtsDeviceStatus>> {
|
||||
const config = getTtsConfig();
|
||||
|
||||
if (!isTtsEnabled()) {
|
||||
logger.info('TTS disabled, skipping device registration', { devEui: device.devEui });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
devEui: device.devEui,
|
||||
deviceId: device.deviceId,
|
||||
name: device.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
logger.info('Registering device in TTS', {
|
||||
devEui: device.devEui,
|
||||
deviceId: device.deviceId,
|
||||
});
|
||||
|
||||
const path = `/api/v3/applications/${config.applicationId}/devices`;
|
||||
|
||||
const body = {
|
||||
end_device: {
|
||||
ids: {
|
||||
device_id: device.deviceId,
|
||||
dev_eui: device.devEui.toUpperCase(),
|
||||
join_eui: device.joinEui.toUpperCase(),
|
||||
application_ids: {
|
||||
application_id: config.applicationId,
|
||||
},
|
||||
},
|
||||
name: device.name,
|
||||
description: device.description || '',
|
||||
lorawan_version: device.lorawanVersion || 'MAC_V1_0_3',
|
||||
lorawan_phy_version: device.lorawanPhyVersion || 'PHY_V1_0_3_REV_A',
|
||||
frequency_plan_id: device.frequencyPlanId || 'US_902_928_FSB_2',
|
||||
supports_join: device.supportsJoin !== false,
|
||||
supports_class_c: device.supportsClassC || false,
|
||||
root_keys: {
|
||||
app_key: {
|
||||
key: device.appKey.toUpperCase(),
|
||||
},
|
||||
...(device.nwkKey && {
|
||||
nwk_key: {
|
||||
key: device.nwkKey.toUpperCase(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
field_mask: {
|
||||
paths: [
|
||||
'name',
|
||||
'description',
|
||||
'lorawan_version',
|
||||
'lorawan_phy_version',
|
||||
'frequency_plan_id',
|
||||
'supports_join',
|
||||
'supports_class_c',
|
||||
'root_keys.app_key.key',
|
||||
...(device.nwkKey ? ['root_keys.nwk_key.key'] : []),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await ttsApiRequest<TtsDeviceStatus>('POST', path, body);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Device registered in TTS successfully', {
|
||||
devEui: device.devEui,
|
||||
deviceId: device.deviceId,
|
||||
});
|
||||
} else {
|
||||
logger.error('Failed to register device in TTS', {
|
||||
devEui: device.devEui,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a device from The Things Stack
|
||||
*
|
||||
* @param devEui - Device EUI
|
||||
* @param deviceId - Device ID in TTS (optional, will use devEui if not provided)
|
||||
* @returns Result of the deletion
|
||||
*/
|
||||
export async function deleteDevice(
|
||||
devEui: string,
|
||||
deviceId?: string
|
||||
): Promise<TtsApiResult<void>> {
|
||||
const config = getTtsConfig();
|
||||
|
||||
if (!isTtsEnabled()) {
|
||||
logger.info('TTS disabled, skipping device deletion', { devEui });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const id = deviceId || devEui.toLowerCase();
|
||||
|
||||
logger.info('Deleting device from TTS', { devEui, deviceId: id });
|
||||
|
||||
const path = `/api/v3/applications/${config.applicationId}/devices/${id}`;
|
||||
|
||||
const result = await ttsApiRequest<void>('DELETE', path);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Device deleted from TTS successfully', { devEui });
|
||||
} else if (result.statusCode === 404) {
|
||||
// Device doesn't exist, consider it a success
|
||||
logger.info('Device not found in TTS, considering deletion successful', { devEui });
|
||||
return { success: true };
|
||||
} else {
|
||||
logger.error('Failed to delete device from TTS', {
|
||||
devEui,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a downlink message to a device
|
||||
*
|
||||
* @param devEui - Device EUI
|
||||
* @param payload - Downlink payload
|
||||
* @param deviceId - Device ID in TTS (optional)
|
||||
* @returns Result of the operation
|
||||
*/
|
||||
export async function sendDownlink(
|
||||
devEui: string,
|
||||
payload: TtsDownlinkPayload,
|
||||
deviceId?: string
|
||||
): Promise<TtsApiResult<{ correlationIds?: string[] }>> {
|
||||
const config = getTtsConfig();
|
||||
|
||||
if (!isTtsEnabled()) {
|
||||
logger.info('TTS disabled, skipping downlink', { devEui });
|
||||
return {
|
||||
success: false,
|
||||
error: 'TTS integration is disabled',
|
||||
};
|
||||
}
|
||||
|
||||
const id = deviceId || devEui.toLowerCase();
|
||||
|
||||
logger.info('Sending downlink to device via TTS', {
|
||||
devEui,
|
||||
deviceId: id,
|
||||
fPort: payload.fPort,
|
||||
confirmed: payload.confirmed,
|
||||
});
|
||||
|
||||
const path = `/api/v3/as/applications/${config.applicationId}/devices/${id}/down/push`;
|
||||
|
||||
const body = {
|
||||
downlinks: [
|
||||
{
|
||||
f_port: payload.fPort,
|
||||
frm_payload: payload.frmPayload,
|
||||
confirmed: payload.confirmed || false,
|
||||
priority: payload.priority || 'NORMAL',
|
||||
...(payload.classBC && {
|
||||
class_b_c: payload.classBC,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await ttsApiRequest<{ correlation_ids?: string[] }>('POST', path, body);
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Downlink queued successfully', {
|
||||
devEui,
|
||||
correlationIds: result.data?.correlation_ids,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
correlationIds: result.data?.correlation_ids,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
logger.error('Failed to send downlink', {
|
||||
devEui,
|
||||
error: result.error,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
statusCode: result.statusCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device status from The Things Stack
|
||||
*
|
||||
* @param devEui - Device EUI
|
||||
* @param deviceId - Device ID in TTS (optional)
|
||||
* @returns Device status information
|
||||
*/
|
||||
export async function getDeviceStatus(
|
||||
devEui: string,
|
||||
deviceId?: string
|
||||
): Promise<TtsApiResult<TtsDeviceStatus>> {
|
||||
const config = getTtsConfig();
|
||||
|
||||
if (!isTtsEnabled()) {
|
||||
logger.info('TTS disabled, skipping device status request', { devEui });
|
||||
return {
|
||||
success: false,
|
||||
error: 'TTS integration is disabled',
|
||||
};
|
||||
}
|
||||
|
||||
const id = deviceId || devEui.toLowerCase();
|
||||
|
||||
logger.debug('Getting device status from TTS', { devEui, deviceId: id });
|
||||
|
||||
const path = `/api/v3/applications/${config.applicationId}/devices/${id}?field_mask=name,description,created_at,updated_at,session,mac_state`;
|
||||
|
||||
const result = await ttsApiRequest<{
|
||||
ids: {
|
||||
device_id: string;
|
||||
dev_eui: string;
|
||||
};
|
||||
name: string;
|
||||
description?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
session?: {
|
||||
dev_addr: string;
|
||||
started_at: string;
|
||||
};
|
||||
mac_state?: {
|
||||
last_dev_status_received_at?: string;
|
||||
battery_percentage?: number;
|
||||
margin?: number;
|
||||
};
|
||||
}>('GET', path);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const data = result.data;
|
||||
const status: TtsDeviceStatus = {
|
||||
devEui: data.ids.dev_eui,
|
||||
deviceId: data.ids.device_id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
createdAt: data.created_at,
|
||||
updatedAt: data.updated_at,
|
||||
session: data.session
|
||||
? {
|
||||
devAddr: data.session.dev_addr,
|
||||
startedAt: data.session.started_at,
|
||||
}
|
||||
: undefined,
|
||||
macState: data.mac_state
|
||||
? {
|
||||
lastDevStatusReceivedAt: data.mac_state.last_dev_status_received_at,
|
||||
batteryPercentage: data.mac_state.battery_percentage,
|
||||
margin: data.mac_state.margin,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
logger.debug('Device status retrieved successfully', {
|
||||
devEui,
|
||||
hasSession: !!status.session,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: status,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.statusCode === 404) {
|
||||
logger.info('Device not found in TTS', { devEui });
|
||||
return {
|
||||
success: false,
|
||||
error: 'Device not found in TTS',
|
||||
statusCode: 404,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
statusCode: result.statusCode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a hex string to base64 for downlink payloads
|
||||
*/
|
||||
export function hexToBase64(hex: string): string {
|
||||
const cleanHex = hex.replace(/\s/g, '');
|
||||
const buffer = Buffer.from(cleanHex, 'hex');
|
||||
return buffer.toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64 string to hex for debugging
|
||||
*/
|
||||
export function base64ToHex(base64: string): string {
|
||||
const buffer = Buffer.from(base64, 'base64');
|
||||
return buffer.toString('hex');
|
||||
}
|
||||
|
||||
export default {
|
||||
isTtsEnabled,
|
||||
registerDevice,
|
||||
deleteDevice,
|
||||
sendDownlink,
|
||||
getDeviceStatus,
|
||||
hexToBase64,
|
||||
base64ToHex,
|
||||
};
|
||||
Reference in New Issue
Block a user