From f31bc09e6cbc64e63662ebeb96e3100482b978f9 Mon Sep 17 00:00:00 2001 From: faris Aljohari <83524184+farisaljohari@users.noreply.github.com> Date: Wed, 17 Jul 2024 10:58:01 +0300 Subject: [PATCH] test --- libs/common/src/database/database.module.ts | 2 +- src/automation/automation.module.ts | 23 ++ .../controllers/automation.controller.ts | 144 +++++++++ src/automation/controllers/index.ts | 1 + src/automation/dtos/automation.dto.ts | 134 ++++++++ src/automation/dtos/index.ts | 1 + .../interface/automation.interface.ts | 23 ++ src/automation/services/automation.service.ts | 304 ++++++++++++++++++ src/automation/services/index.ts | 1 + 9 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 src/automation/automation.module.ts create mode 100644 src/automation/controllers/automation.controller.ts create mode 100644 src/automation/controllers/index.ts create mode 100644 src/automation/dtos/automation.dto.ts create mode 100644 src/automation/dtos/index.ts create mode 100644 src/automation/interface/automation.interface.ts create mode 100644 src/automation/services/automation.service.ts create mode 100644 src/automation/services/index.ts diff --git a/libs/common/src/database/database.module.ts b/libs/common/src/database/database.module.ts index ef5a0a1..14b3d56 100644 --- a/libs/common/src/database/database.module.ts +++ b/libs/common/src/database/database.module.ts @@ -58,7 +58,7 @@ import { DeviceNotificationEntity } from '../modules/device-notification/entitie maxUses: 7500, // close (and replace) a connection after it has been used 7500 times (see below for discussion) }, continuationLocalStorage: true, - ssl: Boolean(JSON.parse(configService.get('DB_SSL'))), + // ssl: Boolean(JSON.parse(configService.get('DB_SSL'))), }), }), ], diff --git a/src/automation/automation.module.ts b/src/automation/automation.module.ts new file mode 100644 index 0000000..d9f9671 --- /dev/null +++ b/src/automation/automation.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { SceneService } from './services/automation.service'; +import { SceneController } from './controllers/automation.controller'; +import { ConfigModule } from '@nestjs/config'; +import { SpaceRepositoryModule } from '@app/common/modules/space/space.repository.module'; +import { SpaceRepository } from '@app/common/modules/space/repositories'; +import { DeviceService } from 'src/device/services'; +import { DeviceRepository } from '@app/common/modules/device/repositories'; +import { ProductRepository } from '@app/common/modules/product/repositories'; + +@Module({ + imports: [ConfigModule, SpaceRepositoryModule], + controllers: [SceneController], + providers: [ + SceneService, + SpaceRepository, + DeviceService, + DeviceRepository, + ProductRepository, + ], + exports: [SceneService], +}) +export class SceneModule {} diff --git a/src/automation/controllers/automation.controller.ts b/src/automation/controllers/automation.controller.ts new file mode 100644 index 0000000..810eaf4 --- /dev/null +++ b/src/automation/controllers/automation.controller.ts @@ -0,0 +1,144 @@ +import { AutomationService } from '../services/automation.service'; +import { + Body, + Controller, + Delete, + Get, + HttpException, + HttpStatus, + Param, + Post, + Put, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { + AddAutomationDto, + UpdateSceneTapToRunDto, +} from '../dtos/automation.dto'; +import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; + +@ApiTags('Automation Module') +@Controller({ + version: '1', + path: 'automation', +}) +export class AutomationController { + constructor(private readonly automationService: AutomationService) {} + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Post() + async addAutomation(@Body() addAutomationDto: AddAutomationDto) { + try { + const sceneAutomation = + await this.automationService.addAutomation(addAutomationDto); + return { + statusCode: HttpStatus.CREATED, + success: true, + message: 'Automation added successfully', + data: sceneAutomation, + }; + } catch (error) { + throw new HttpException( + error.message || 'Internal server error', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get('tap-to-run/:unitUuid') + async getTapToRunSceneByUnit(@Param('unitUuid') unitUuid: string) { + try { + const tapToRunScenes = + await this.automationService.getTapToRunSceneByUnit(unitUuid); + return tapToRunScenes; + } catch (error) { + throw new HttpException( + error.message || 'Internal server error', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Delete('tap-to-run/:unitUuid/:sceneId') + async deleteTapToRunScene( + @Param('unitUuid') unitUuid: string, + @Param('sceneId') sceneId: string, + ) { + try { + await this.automationService.deleteTapToRunScene(unitUuid, sceneId); + return { + statusCode: HttpStatus.OK, + message: 'Scene Deleted Successfully', + }; + } catch (error) { + throw new HttpException( + error.message || 'Internal server error', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Post('tap-to-run/trigger/:sceneId') + async triggerTapToRunScene(@Param('sceneId') sceneId: string) { + try { + await this.automationService.triggerTapToRunScene(sceneId); + return { + statusCode: HttpStatus.CREATED, + success: true, + message: 'Scene trigger successfully', + }; + } catch (error) { + throw new HttpException( + error.message || 'Internal server error', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get('tap-to-run/details/:sceneId') + async getTapToRunSceneDetails(@Param('sceneId') sceneId: string) { + try { + const tapToRunScenes = + await this.automationService.getTapToRunSceneDetails(sceneId); + return tapToRunScenes; + } catch (error) { + throw new HttpException( + error.message || 'Internal server error', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + ``; + } + } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Put('tap-to-run/:sceneId') + async updateTapToRunScene( + @Body() updateSceneTapToRunDto: UpdateSceneTapToRunDto, + @Param('sceneId') sceneId: string, + ) { + try { + const tapToRunScene = await this.automationService.updateTapToRunScene( + updateSceneTapToRunDto, + sceneId, + ); + return { + statusCode: HttpStatus.CREATED, + success: true, + message: 'Scene updated successfully', + data: tapToRunScene, + }; + } catch (error) { + throw new HttpException( + error.message || 'Internal server error', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/automation/controllers/index.ts b/src/automation/controllers/index.ts new file mode 100644 index 0000000..4d9bacc --- /dev/null +++ b/src/automation/controllers/index.ts @@ -0,0 +1 @@ +export * from './automation.controller'; diff --git a/src/automation/dtos/automation.dto.ts b/src/automation/dtos/automation.dto.ts new file mode 100644 index 0000000..b930927 --- /dev/null +++ b/src/automation/dtos/automation.dto.ts @@ -0,0 +1,134 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsNotEmpty, + IsString, + IsArray, + ValidateNested, + IsOptional, + IsNumber, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +class ExecutorProperty { + @ApiProperty({ + description: 'Function code (for device issue action)', + required: false, + }) + @IsString() + @IsOptional() + public functionCode?: string; + + @ApiProperty({ + description: 'Function value (for device issue action)', + required: false, + }) + @IsOptional() + public functionValue?: any; + + @ApiProperty({ + description: 'Delay in seconds (for delay action)', + required: false, + }) + @IsNumber() + @IsOptional() + public delaySeconds?: number; +} + +class Action { + @ApiProperty({ + description: 'Entity ID', + required: true, + }) + @IsString() + @IsNotEmpty() + public entityId: string; + + @ApiProperty({ + description: 'Action executor', + required: true, + }) + @IsString() + @IsNotEmpty() + public actionExecutor: string; + + @ApiProperty({ + description: 'Executor property', + required: false, // Set required to false + type: ExecutorProperty, + }) + @ValidateNested() + @Type(() => ExecutorProperty) + @IsOptional() // Make executorProperty optional + public executorProperty?: ExecutorProperty; +} + +export class AddAutomationDto { + @ApiProperty({ + description: 'Unit UUID', + required: true, + }) + @IsString() + @IsNotEmpty() + public unitUuid: string; + + @ApiProperty({ + description: 'Scene name', + required: true, + }) + @IsString() + @IsNotEmpty() + public sceneName: string; + + @ApiProperty({ + description: 'Decision expression', + required: true, + }) + @IsString() + @IsNotEmpty() + public decisionExpr: string; + + @ApiProperty({ + description: 'Actions', + required: true, + type: [Action], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Action) + public actions: Action[]; + + constructor(dto: Partial) { + Object.assign(this, dto); + } +} +export class UpdateSceneTapToRunDto { + @ApiProperty({ + description: 'Scene name', + required: true, + }) + @IsString() + @IsNotEmpty() + public sceneName: string; + + @ApiProperty({ + description: 'Decision expression', + required: true, + }) + @IsString() + @IsNotEmpty() + public decisionExpr: string; + + @ApiProperty({ + description: 'Actions', + required: true, + type: [Action], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Action) + public actions: Action[]; + + constructor(dto: Partial) { + Object.assign(this, dto); + } +} diff --git a/src/automation/dtos/index.ts b/src/automation/dtos/index.ts new file mode 100644 index 0000000..4cdad58 --- /dev/null +++ b/src/automation/dtos/index.ts @@ -0,0 +1 @@ +export * from './automation.dto'; diff --git a/src/automation/interface/automation.interface.ts b/src/automation/interface/automation.interface.ts new file mode 100644 index 0000000..00ebcae --- /dev/null +++ b/src/automation/interface/automation.interface.ts @@ -0,0 +1,23 @@ +export interface AddTapToRunSceneInterface { + success: boolean; + msg?: string; + result: { + id: string; + }; +} +export interface GetTapToRunSceneByUnitInterface { + success: boolean; + msg?: string; + result: { + list: Array<{ + id: string; + name: string; + status: string; + }>; + }; +} +export interface DeleteTapToRunSceneInterface { + success: boolean; + msg?: string; + result: boolean; +} diff --git a/src/automation/services/automation.service.ts b/src/automation/services/automation.service.ts new file mode 100644 index 0000000..fe54ac0 --- /dev/null +++ b/src/automation/services/automation.service.ts @@ -0,0 +1,304 @@ +import { + Injectable, + HttpException, + HttpStatus, + BadRequestException, +} from '@nestjs/common'; +import { SpaceRepository } from '@app/common/modules/space/repositories'; +import { AddAutomationDto, UpdateSceneTapToRunDto } from '../dtos'; +import { GetUnitByUuidInterface } from 'src/unit/interface/unit.interface'; +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 { + AddTapToRunSceneInterface, + DeleteTapToRunSceneInterface, + GetTapToRunSceneByUnitInterface, +} from '../interface/automation.interface'; +import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; + +@Injectable() +export class AutomationService { + private tuya: TuyaContext; + constructor( + private readonly configService: ConfigService, + private readonly spaceRepository: SpaceRepository, + private readonly deviceService: DeviceService, + ) { + 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, spaceTuyaId = null) { + try { + let unitSpaceTuyaId; + if (!spaceTuyaId) { + const unitDetails = await this.getUnitByUuid(addAutomationDto.unitUuid); + unitSpaceTuyaId = unitDetails.spaceTuyaUuid; + if (!unitDetails) { + throw new BadRequestException('Invalid unit UUID'); + } + } else { + unitSpaceTuyaId = spaceTuyaId; + } + const actions = addAutomationDto.actions.map((action) => { + return { + ...action, + }; + }); + + const convertedData = convertKeysToSnakeCase(actions); + for (const action of convertedData) { + if (action.action_executor === 'device_issue') { + const device = await this.deviceService.getDeviceByDeviceUuid( + action.entity_id, + false, + ); + if (device) { + action.entity_id = device.deviceTuyaUuid; + } + } + } + const path = `/v2.0/cloud/scene/rule`; + const response: AddTapToRunSceneInterface = await this.tuya.request({ + method: 'POST', + path, + body: { + space_id: unitSpaceTuyaId, + name: addAutomationDto.sceneName, + type: 'scene', + decision_expr: addAutomationDto.decisionExpr, + actions: convertedData, + }, + }); + if (!response.success) { + throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); + } + return { + id: response.result.id, + }; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException( + err.message || 'Scene not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } + async getUnitByUuid(unitUuid: string): Promise { + try { + const unit = await this.spaceRepository.findOne({ + where: { + uuid: unitUuid, + spaceType: { + type: 'unit', + }, + }, + relations: ['spaceType'], + }); + if (!unit || !unit.spaceType || unit.spaceType.type !== 'unit') { + throw new BadRequestException('Invalid unit UUID'); + } + return { + uuid: unit.uuid, + createdAt: unit.createdAt, + updatedAt: unit.updatedAt, + name: unit.spaceName, + type: unit.spaceType.type, + spaceTuyaUuid: unit.spaceTuyaUuid, + }; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException('Unit not found', HttpStatus.NOT_FOUND); + } + } + } + async getTapToRunSceneByUnit(unitUuid: string) { + try { + const unit = await this.getUnitByUuid(unitUuid); + if (!unit.spaceTuyaUuid) { + throw new BadRequestException('Invalid unit UUID'); + } + + const path = `/v2.0/cloud/scene/rule?space_id=${unit.spaceTuyaUuid}&type=scene`; + const response: GetTapToRunSceneByUnitInterface = await this.tuya.request( + { + method: 'GET', + path, + }, + ); + + if (!response.success) { + throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); + } + + return response.result.list.map((item) => { + return { + id: item.id, + name: item.name, + status: item.status, + type: 'tap_to_run', + }; + }); + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException( + err.message || 'Scene not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } + async deleteTapToRunScene( + unitUuid: string, + sceneId: string, + spaceTuyaId = null, + ) { + try { + let unitSpaceTuyaId; + if (!spaceTuyaId) { + const unitDetails = await this.getUnitByUuid(unitUuid); + unitSpaceTuyaId = unitDetails.spaceTuyaUuid; + if (!unitSpaceTuyaId) { + throw new BadRequestException('Invalid unit UUID'); + } + } else { + unitSpaceTuyaId = spaceTuyaId; + } + + const path = `/v2.0/cloud/scene/rule?ids=${sceneId}&space_id=${unitSpaceTuyaId}`; + const response: DeleteTapToRunSceneInterface = await this.tuya.request({ + method: 'DELETE', + path, + }); + + if (!response.success) { + throw new HttpException('Scene not found', HttpStatus.NOT_FOUND); + } + + return response; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException( + err.message || 'Scene not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } + async triggerTapToRunScene(sceneId: string) { + try { + const path = `/v2.0/cloud/scene/rule/${sceneId}/actions/trigger`; + const response: DeleteTapToRunSceneInterface = await this.tuya.request({ + method: 'POST', + path, + }); + if (!response.success) { + throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); + } + return response; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException( + err.message || 'Scene not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } + async getTapToRunSceneDetails(sceneId: string, withSpaceId = false) { + try { + const path = `/v2.0/cloud/scene/rule/${sceneId}`; + const response = await this.tuya.request({ + method: 'GET', + path, + }); + if (!response.success) { + throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); + } + const responseData = convertKeysToCamelCase(response.result); + const actions = responseData.actions.map((action) => { + return { + ...action, + }; + }); + + for (const action of actions) { + if (action.actionExecutor === 'device_issue') { + const device = await this.deviceService.getDeviceByDeviceTuyaUuid( + action.entityId, + ); + + if (device) { + action.entityId = device.uuid; + } + } + } + + return { + id: responseData.id, + name: responseData.name, + status: responseData.status, + type: 'tap_to_run', + actions: actions, + ...(withSpaceId && { spaceId: responseData.spaceId }), + }; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException('Scene not found', HttpStatus.NOT_FOUND); + } + } + } + async updateTapToRunScene( + updateSceneTapToRunDto: UpdateSceneTapToRunDto, + sceneId: string, + ) { + try { + const spaceTuyaId = await this.getTapToRunSceneDetails(sceneId, true); + if (!spaceTuyaId.spaceId) { + throw new HttpException("Scene doesn't exist", HttpStatus.NOT_FOUND); + } + const addSceneTapToRunDto: AddAutomationDto = { + ...updateSceneTapToRunDto, + unitUuid: null, + }; + const newTapToRunScene = await this.addAutomation( + addSceneTapToRunDto, + spaceTuyaId.spaceId, + ); + if (newTapToRunScene.id) { + await this.deleteTapToRunScene(null, sceneId, spaceTuyaId.spaceId); + return newTapToRunScene; + } + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException( + err.message || 'Scene not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } +} diff --git a/src/automation/services/index.ts b/src/automation/services/index.ts new file mode 100644 index 0000000..16f2aeb --- /dev/null +++ b/src/automation/services/index.ts @@ -0,0 +1 @@ +export * from './automation.service';