feat: integration with API Vega Cloud

This commit is contained in:
2025-10-14 11:16:23 +07:00
parent 8b24695a75
commit 1d341c3a7c
23 changed files with 468 additions and 431 deletions
+19
View File
@@ -0,0 +1,19 @@
import { httpRequest } from '~/helpers/http/axios'
import logger from '~/plugins/logger'
export async function sendCommandToThirdParty(topicData: string, payload: string): Promise<void> {
try {
const response = await httpRequest({
method: 'POST',
url: '/command',
body: {
topic: topicData,
payload,
},
})
logger.info({ msg: 'Third-party command sent successfully', topic: topicData, status: response.status })
} catch (error) {
logger.error({ msg: 'Failed to send command to third-party service', topic: topicData, error: (error as Error).message })
throw new Error('third_party_command_failed')
}
}
+14 -3
View File
@@ -1,5 +1,6 @@
import { verify } from '~/helpers/signature'
import { DeviceCommandBody } from '../schema'
import { sendCommandToThirdParty } from './command'
export default abstract class CommandService {
static async sendCommand(body: DeviceCommandBody, headers: Record<string, string | undefined>): Promise<{
@@ -22,10 +23,20 @@ export default abstract class CommandService {
// TODO: Check nonce for replay attack when DB is implemented
return {
status: 'success',
message: 'Command accepted',
const result = {
status: 'success' as const,
message: 'Command accepted' as const,
receivedAt: new Date().toISOString(),
}
// Generate topicData and send to third-party service
try {
const topicData = `${body.merchantName}/${body.floorName}_${body.unitNumber}-${body.deviceName}-${body.roomName}-${body.deviceType}-${body.commandType}-${body.towerNumber}`
await sendCommandToThirdParty(topicData, body.payload.AC)
} catch {
throw new Error('internal_error')
}
return result
}
}
+43
View File
@@ -0,0 +1,43 @@
import { httpRequest } from '~/helpers/http/axios'
import logger from '~/plugins/logger'
export async function queryDeviceStatus(topic: string): Promise<{
floor: string
unit: string
device: string
room: string
devType: string
code: string
tower: string
payload: string
} | null> {
try {
const response = await httpRequest({
method: 'GET',
url: '/state-reply',
body: { topic },
})
if ((response.data as Record<string, unknown>) && (response.data as Record<string, unknown>).data) {
const thirdPartyData = (response.data as Record<string, unknown>).data as Record<string, unknown>
logger.info({ msg: 'Device status queried successfully', topic, status: response.status })
return {
floor: thirdPartyData.floor as string,
unit: thirdPartyData.unit as string,
device: thirdPartyData.device as string,
room: thirdPartyData.room as string,
devType: thirdPartyData.devType as string,
code: thirdPartyData.code as string,
tower: thirdPartyData.tower as string,
payload: thirdPartyData.payload as string,
}
} else {
logger.info({ msg: 'Device status not found', topic, status: response.status })
return null
}
} catch (error) {
logger.error({ msg: 'Failed to query device status', topic, error: (error as Error).message })
throw new Error('third_party_query_failed')
}
}
+49
View File
@@ -0,0 +1,49 @@
import { verify } from '~/helpers/signature'
import { DeviceQueryParams } from '../schema'
import { queryDeviceStatus } from './query'
export default abstract class QueryService {
static async getDeviceStatus(query: DeviceQueryParams, headers: Record<string, string | undefined>): Promise<{
status: 'success'
message: 'command accepted'
data: {
floor: string
unit: string
device: string
room: string
devType: string
code: string
tower: string
payload: string
}
} | null> {
const apiKey = headers['x-api-key']
// For now, use apiKey as secretKey since DB validation is skipped
const secretKey = apiKey
// Remove signature from query for verification
const { signature, ...queryWithoutSignature } = query
const isValid = verify(queryWithoutSignature, signature, secretKey)
if (!isValid) {
throw new Error('invalid_signature')
}
// TODO: Check nonce for replay attack when DB is implemented
const topic = `${query.merchantName}/${query.floorName}_${query.unitNumber}-${query.deviceName}-${query.roomName}-${query.deviceType}-${query.commandType}-${query.towerNumber}`
const deviceData = await queryDeviceStatus(topic)
if (deviceData) {
return {
status: 'success',
message: 'command accepted',
data: deviceData,
}
}
return null
}
}
+32 -1
View File
@@ -1,7 +1,8 @@
import { Elysia } from 'elysia'
import { deviceGuard } from '~/middlewares/deviceGuard'
import { deviceCommandBody, deviceCommandResponseSchema } from './schema'
import { deviceCommandBody, deviceCommandResponseSchema, deviceQueryParams, deviceQueryResponseSchema } from './schema'
import CommandService from './commands/service'
import QueryService from './queries/service'
import { deviceErrorResponseMap } from '~/helpers/errors'
export const router = new Elysia({
@@ -35,5 +36,35 @@ export const router = new Elysia({
],
},
})
.get('/status', async ({ query, headers, set }) => {
try {
const result = await QueryService.getDeviceStatus(query, headers)
if (result) {
return result
} else {
set.status = 404
return { status: 'not_found', message: 'Device status not found' }
}
} catch (error) {
if (error instanceof Error && error.message in deviceErrorResponseMap) {
const { status, response } = deviceErrorResponseMap[error.message]
set.status = status
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return response as any
}
set.status = 500
return { status: 'internal_error', message: 'Server error' }
}
}, {
query: deviceQueryParams,
response: deviceQueryResponseSchema,
detail: {
security: [
{ apiKey: [] },
{ timestamp: [] },
{ signature: [] },
],
},
})
export default router
+60
View File
@@ -20,6 +20,22 @@ export const deviceCommandBody = z.object({
})
export type DeviceCommandBody = z.infer<typeof deviceCommandBody>
// Request query schema for GET endpoint
export const deviceQueryParams = z.object({
merchantName: z.string(),
floorName: z.string(),
unitNumber: z.string(),
deviceName: z.string(),
roomName: z.string(),
deviceType: z.string(),
commandType: z.string(),
towerNumber: z.string(),
timestamp: z.string().datetime(), // ISO8601
nonce: z.string(),
signature: z.string(),
})
export type DeviceQueryParams = z.infer<typeof deviceQueryParams>
// Response schemas
export const deviceCommandResponseSchema = {
200: z.object({
@@ -45,6 +61,50 @@ export const deviceCommandResponseSchema = {
status: z.literal('replay_attack_detected'),
message: z.literal('Timestamp or nonce is invalid/has been reused'),
}),
404: z.object({
status: z.literal('not_found'),
message: z.literal('Device status not found'),
}),
500: z.object({
status: z.literal('internal_error'),
message: z.literal('Server error'),
}),
}
// Query response schema
export const deviceQueryResponseSchema = {
200: z.object({
status: z.literal('success'),
message: z.literal('command accepted'),
data: z.object({
floor: z.string(),
unit: z.string(),
device: z.string(),
room: z.string(),
devType: z.string(),
code: z.string(),
tower: z.string(),
payload: z.string(),
}),
}),
400: z.object({
status: z.literal('invalid_request'),
message: z.literal('Invalid request format'),
}),
401: z.union([
z.object({
status: z.literal('invalid_signature'),
message: z.literal('Signature mismatch'),
}),
z.object({
status: z.literal('invalid_api_key'),
message: z.literal('API Key is not valid'),
}),
]),
403: z.object({
status: z.literal('replay_attack_detected'),
message: z.literal('Timestamp or nonce is invalid/has been reused'),
}),
500: z.object({
status: z.literal('internal_error'),
message: z.literal('Server error'),