Files
GRH/water-api/src/services/tts/ttsApi.service.ts
Exteban08 c81a18987f 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>
2026-01-23 10:13:26 +00:00

519 lines
12 KiB
TypeScript

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,
};