import { Injectable, HttpException, HttpStatus, BadRequestException, } from '@nestjs/common'; import { SpaceRepository } from '@app/common/modules/space/repositories'; import { AddAutomationDto, AutomationParamDto, UpdateAutomationDto, UpdateAutomationStatusDto, } from '../dtos'; import { ConfigService } from '@nestjs/config'; import { TuyaContext } from '@tuya/tuya-connector-nodejs'; import { convertKeysToSnakeCase } from '@app/common/helper/snakeCaseConverter'; import { DeviceService } from 'src/device/services'; import { Action, AddAutomationParams, AutomationDetailsResult, AutomationResponseData, Condition, GetAutomationBySpaceInterface, } from '../interface/automation.interface'; import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; import { ActionExecutorEnum, AUTO_PREFIX, AUTOMATION_TYPE, EntityTypeEnum, } from '@app/common/constants/automation.enum'; import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; import { ConvertedAction } from '@app/common/integrations/tuya/interfaces'; import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; @Injectable() export class AutomationService { private tuya: TuyaContext; constructor( private readonly configService: ConfigService, private readonly spaceRepository: SpaceRepository, private readonly deviceService: DeviceService, private readonly tuyaService: TuyaService, private readonly sceneDeviceRepository: SceneDeviceRepository, ) { const accessKey = this.configService.get('auth-config.ACCESS_KEY'); const secretKey = this.configService.get('auth-config.SECRET_KEY'); const tuyaEuUrl = this.configService.get('tuya-config.TUYA_EU_URL'); this.tuya = new TuyaContext({ baseUrl: tuyaEuUrl, accessKey, secretKey, }); } async addAutomation(addAutomationDto: AddAutomationDto) { try { const { automationName, effectiveTime, decisionExpr, actions, conditions, } = addAutomationDto; const space = await this.getSpaceByUuid(addAutomationDto.spaceUuid); const response = await this.add({ automationName, effectiveTime, decisionExpr, actions, conditions, spaceTuyaId: space.spaceTuyaUuid, }); return response; } catch (err) { if (err instanceof BadRequestException) { throw err; } else { throw new HttpException( err.message || 'Automation not found', err.status || HttpStatus.NOT_FOUND, ); } } } async add(params: AddAutomationParams) { try { const formattedActions = await this.prepareActions(params.actions); const formattedCondition = await this.prepareConditions( params.conditions, ); const response = await this.tuyaService.createAutomation( params.spaceTuyaId, params.automationName, params.effectiveTime, params.decisionExpr, formattedCondition, formattedActions, ); return { id: response?.result.id, }; } catch (error) { throw new HttpException( error.message || 'Failed to add automation', error.status || HttpStatus.INTERNAL_SERVER_ERROR, ); } } async getSpaceByUuid(spaceUuid: string) { try { const space = await this.spaceRepository.findOne({ where: { uuid: spaceUuid, }, relations: ['community'], }); if (!space) { throw new BadRequestException(`Invalid space UUID ${spaceUuid}`); } return { uuid: space.uuid, createdAt: space.createdAt, updatedAt: space.updatedAt, name: space.spaceName, spaceTuyaUuid: space.community.externalId, }; } catch (err) { if (err instanceof BadRequestException) { throw err; // Re-throw BadRequestException } else { throw new HttpException('Space not found', HttpStatus.NOT_FOUND); } } } async getAutomationBySpace(spaceUuid: string) { try { const space = await this.getSpaceByUuid(spaceUuid); if (!space.spaceTuyaUuid) { throw new BadRequestException(`Invalid space UUID ${spaceUuid}`); } const path = `/v2.0/cloud/scene/rule?space_id=${space.spaceTuyaUuid}&type=automation`; const response: GetAutomationBySpaceInterface = await this.tuya.request({ method: 'GET', path, }); if (!response.success) { throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); } return response.result.list .filter((item) => item.name && !item.name.startsWith(AUTO_PREFIX)) .map((item) => { return { id: item.id, name: item.name, status: item.status, type: AUTOMATION_TYPE, }; }); } catch (err) { if (err instanceof BadRequestException) { throw err; // Re-throw BadRequestException } else { throw new HttpException( err.message || 'Automation not found', err.status || HttpStatus.NOT_FOUND, ); } } } async getTapToRunSceneDetailsTuya( sceneUuid: string, ): Promise { try { const path = `/v2.0/cloud/scene/rule/${sceneUuid}`; const response = await this.tuya.request({ method: 'GET', path, }); if (!response.success) { throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); } const camelCaseResponse = convertKeysToCamelCase(response); const { id, name, type } = camelCaseResponse.result; return { id, name, type, } as AutomationDetailsResult; } catch (err) { if (err instanceof BadRequestException) { throw err; // Re-throw BadRequestException } else { throw new HttpException( 'Scene not found for Tuya', HttpStatus.NOT_FOUND, ); } } } async getAutomationDetails(automationUuid: string, withSpaceId = false) { try { const path = `/v2.0/cloud/scene/rule/${automationUuid}`; const response = await this.tuya.request({ method: 'GET', path, }); if (!response.success) { throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); } const responseData: AutomationResponseData = convertKeysToCamelCase( response.result, ); const actions = responseData.actions.map((action) => ({ ...action, })); for (const action of actions) { if (action.actionExecutor === ActionExecutorEnum.DEVICE_ISSUE) { const device = await this.deviceService.getDeviceByDeviceTuyaUuid( action.entityId, ); if (device) { action.entityId = device.uuid; } } else if ( action.actionExecutor !== ActionExecutorEnum.DEVICE_ISSUE && action.actionExecutor !== ActionExecutorEnum.DELAY ) { const sceneDetails = await this.getTapToRunSceneDetailsTuya( action.entityId, ); if (sceneDetails.id) { action.name = sceneDetails.name; action.type = sceneDetails.type; } } } const conditions = responseData.conditions.map((condition) => ({ ...condition, })); for (const condition of conditions) { if (condition.entityType === EntityTypeEnum.DEVICE_REPORT) { const device = await this.deviceService.getDeviceByDeviceTuyaUuid( condition.entityId, ); if (device) { condition.entityId = device.uuid; } } } // eslint-disable-next-line @typescript-eslint/no-unused-vars const { timeZoneId, ...effectiveTimeWithoutTimeZoneId } = responseData.effectiveTime || {}; return { id: responseData.id, name: responseData.name, status: responseData.status, type: 'automation', ...(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { spaceId, runningMode, ...rest } = responseData; return rest; })(), actions, conditions, effectiveTime: effectiveTimeWithoutTimeZoneId, // Use modified effectiveTime ...(withSpaceId && { spaceId: responseData.spaceId }), }; } catch (err) { if (err instanceof BadRequestException) { throw err; // Re-throw BadRequestException } else { throw new HttpException('Automation not found', HttpStatus.NOT_FOUND); } } } async deleteAutomation(param: AutomationParamDto) { try { const { automationUuid } = param; const automation = await this.getAutomationDetails(automationUuid, true); if (!automation && !automation.spaceId) { throw new HttpException( `Invalid automationid ${automationUuid}`, HttpStatus.BAD_REQUEST, ); } const existingSceneDevice = await this.sceneDeviceRepository.findOne({ where: { automationTuyaUuid: automationUuid }, }); if (existingSceneDevice) { await this.sceneDeviceRepository.delete({ automationTuyaUuid: automationUuid, }); } const response = this.tuyaService.deleteAutomation( automation.spaceId, automationUuid, ); return response; } catch (err) { if (err instanceof HttpException) { throw err; } else { throw new HttpException( err.message || 'Automation not found', err.status || HttpStatus.NOT_FOUND, ); } } } async delete(tuyaSpaceId: string, automationUuid: string) { try { const existingSceneDevice = await this.sceneDeviceRepository.findOne({ where: { automationTuyaUuid: automationUuid }, }); if (existingSceneDevice) { await this.sceneDeviceRepository.delete({ automationTuyaUuid: automationUuid, }); } const response = await this.tuyaService.deleteAutomation( tuyaSpaceId, automationUuid, ); return response; } catch (err) { if (err instanceof HttpException) { throw err; } else { throw new HttpException( err.message || 'Automation not found', err.status || HttpStatus.NOT_FOUND, ); } } } async updateAutomation( updateAutomationDto: UpdateAutomationDto, automationUuid: string, ) { const { actions, conditions, automationName, effectiveTime, decisionExpr } = updateAutomationDto; try { const automation = await this.getAutomationDetails(automationUuid, true); if (!automation.spaceId) { throw new HttpException( "Automation doesn't exist", HttpStatus.NOT_FOUND, ); } const newAutomation = await this.add({ actions, conditions, automationName, effectiveTime, decisionExpr, spaceTuyaId: automation.spaceId, }); if (newAutomation.id) { await this.delete(automation.spaceId, automationUuid); return newAutomation; } } catch (err) { if (err instanceof BadRequestException) { throw err; // Re-throw BadRequestException } else { throw new HttpException( err.message || 'Automation not found', err.status || HttpStatus.NOT_FOUND, ); } } } async updateAutomationStatus( updateAutomationStatusDto: UpdateAutomationStatusDto, automationUuid: string, ) { const { isEnable, spaceUuid } = updateAutomationStatusDto; try { const space = await this.getSpaceByUuid(spaceUuid); if (!space.spaceTuyaUuid) { throw new HttpException( `Invalid space UUID ${spaceUuid}`, HttpStatus.NOT_FOUND, ); } const response = await this.tuyaService.updateAutomationState( space.spaceTuyaUuid, automationUuid, isEnable, ); return response; } catch (err) { if (err instanceof BadRequestException) { throw err; // Re-throw BadRequestException } else { throw new HttpException( err.message || 'Automation not found', err.status || HttpStatus.NOT_FOUND, ); } } } private async prepareActions(actions: Action[]): Promise { const convertedData = convertKeysToSnakeCase(actions) as ConvertedAction[]; await Promise.all( convertedData.map(async (action) => { if (action.action_executor === ActionExecutorEnum.DEVICE_ISSUE) { const device = await this.deviceService.getDeviceByDeviceUuid( action.entity_id, false, ); if (device) { action.entity_id = device.deviceTuyaUuid; } } }), ); return convertedData; } private async prepareConditions(conditions: Condition[]) { const convertedData = convertKeysToSnakeCase(conditions); await Promise.all( convertedData.map(async (condition) => { if (condition.entity_type === EntityTypeEnum.DEVICE_REPORT) { const device = await this.deviceService.getDeviceByDeviceUuid( condition.entity_id, false, ); if (device) { condition.entity_id = device.deviceTuyaUuid; } } }), ); return convertedData; } }