├── app ├── src │ ├── models │ │ ├── index.ts │ │ ├── device.ts │ │ └── device.spec.ts │ ├── validation │ │ ├── index.ts │ │ └── trigger.dto.ts │ ├── services │ │ ├── trigger │ │ │ ├── index.ts │ │ │ ├── trigger.service.spec.ts │ │ │ └── trigger.service.ts │ │ └── device │ │ │ ├── device.service.ts │ │ │ └── device.service.spec.ts │ ├── controllers │ │ └── trigger │ │ │ ├── index.ts │ │ │ ├── trigger.controller.spec.ts │ │ │ └── trigger.controller.ts │ ├── app.module.ts │ └── main.ts ├── .prettierrc ├── nest-cli.json ├── tsconfig.build.json ├── assets │ ├── ifttt_maker_webhooks.png │ ├── ifttt_webhooks_create.gif │ ├── ifttt_google_assistant.gif │ ├── ifttt_maker_webhooks_key.png │ ├── timer-for-google-assistant.png │ ├── ifttt_webhooks_make_http_request.png │ ├── ifttt_webhooks_trigger_event_name.gif │ ├── ifttt_webhooks_trigger_event_target.gif │ ├── ifttt_google_assistant_number_trigger.png │ ├── ifttt_webhooks_create_google_assistant.gif │ └── ifttt_google_assistant_trigger_event_target.gif ├── .env.example ├── test │ ├── jest-e2e.json │ └── app.e2e-spec.ts ├── tsconfig.json ├── .eslintrc.js └── package.json ├── root ├── etc │ ├── services.d │ │ └── timer-for-google-assistant │ │ │ └── run │ └── cont-init.d │ │ ├── 01-envfile │ │ └── 10-adduser └── usr │ └── bin │ └── with-contenv ├── .dockerignore ├── .gitignore ├── docker-compose.yml ├── LICENSE ├── .gitattributes ├── Dockerfile └── README.md /app/src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './device' -------------------------------------------------------------------------------- /app/src/validation/index.ts: -------------------------------------------------------------------------------- 1 | export * from './trigger.dto' -------------------------------------------------------------------------------- /app/src/services/trigger/index.ts: -------------------------------------------------------------------------------- 1 | export * from './trigger.service' -------------------------------------------------------------------------------- /app/src/controllers/trigger/index.ts: -------------------------------------------------------------------------------- 1 | export * from './trigger.controller' -------------------------------------------------------------------------------- /app/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /app/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /root/etc/services.d/timer-for-google-assistant/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bash 2 | npm run --prefix /app start:prod 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .cache 3 | app 4 | **/*.md 5 | .gitignore 6 | docker-compose.yaml 7 | docker-compose.yml 8 | LICENSE 9 | -------------------------------------------------------------------------------- /app/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /app/assets/ifttt_maker_webhooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiseindy/timer-for-google-assistant/HEAD/app/assets/ifttt_maker_webhooks.png -------------------------------------------------------------------------------- /app/assets/ifttt_webhooks_create.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiseindy/timer-for-google-assistant/HEAD/app/assets/ifttt_webhooks_create.gif -------------------------------------------------------------------------------- /app/src/models/device.ts: -------------------------------------------------------------------------------- 1 | export class Device { 2 | name: string; 3 | targetState: boolean; 4 | added: Date; 5 | expiry: Date; 6 | } 7 | -------------------------------------------------------------------------------- /app/assets/ifttt_google_assistant.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiseindy/timer-for-google-assistant/HEAD/app/assets/ifttt_google_assistant.gif -------------------------------------------------------------------------------- /app/assets/ifttt_maker_webhooks_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiseindy/timer-for-google-assistant/HEAD/app/assets/ifttt_maker_webhooks_key.png -------------------------------------------------------------------------------- /app/assets/timer-for-google-assistant.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiseindy/timer-for-google-assistant/HEAD/app/assets/timer-for-google-assistant.png -------------------------------------------------------------------------------- /app/assets/ifttt_webhooks_make_http_request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiseindy/timer-for-google-assistant/HEAD/app/assets/ifttt_webhooks_make_http_request.png -------------------------------------------------------------------------------- /app/assets/ifttt_webhooks_trigger_event_name.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiseindy/timer-for-google-assistant/HEAD/app/assets/ifttt_webhooks_trigger_event_name.gif -------------------------------------------------------------------------------- /app/assets/ifttt_webhooks_trigger_event_target.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiseindy/timer-for-google-assistant/HEAD/app/assets/ifttt_webhooks_trigger_event_target.gif -------------------------------------------------------------------------------- /app/.env.example: -------------------------------------------------------------------------------- 1 | PORT=3020 2 | SECURITY_KEY=ChangeThisToSomethingSecure 3 | IFTTT_EVENT_OFF_SUFFIX=_off 4 | IFTTT_EVENT_ON_SUFFIX=_on 5 | IFTTT_EVENT_KEY=xxxxxxxxxxxxxxxxxxxxxx -------------------------------------------------------------------------------- /app/assets/ifttt_google_assistant_number_trigger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiseindy/timer-for-google-assistant/HEAD/app/assets/ifttt_google_assistant_number_trigger.png -------------------------------------------------------------------------------- /app/assets/ifttt_webhooks_create_google_assistant.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiseindy/timer-for-google-assistant/HEAD/app/assets/ifttt_webhooks_create_google_assistant.gif -------------------------------------------------------------------------------- /app/assets/ifttt_google_assistant_trigger_event_target.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wiseindy/timer-for-google-assistant/HEAD/app/assets/ifttt_google_assistant_trigger_event_target.gif -------------------------------------------------------------------------------- /app/src/models/device.spec.ts: -------------------------------------------------------------------------------- 1 | import { Device } from './device'; 2 | 3 | describe('Device', () => { 4 | it('should be defined', () => { 5 | expect(new Device()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /app/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /root/usr/bin/with-contenv: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | if [[ -f /var/run/s6/container_environment/UMASK ]] && [[ "$(pwdx $$)" =~ "/run/s6/services/" ]]; then 3 | umask $(cat /var/run/s6/container_environment/UMASK) 4 | exec /usr/bin/with-contenvb "$@" 5 | else 6 | exec /usr/bin/with-contenvb "$@" 7 | fi -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true 13 | }, 14 | "exclude": ["node_modules", "dist"] 15 | } 16 | -------------------------------------------------------------------------------- /app/src/validation/trigger.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber, IsString, IsOptional, IsBoolean } from 'class-validator'; 2 | 3 | export class TriggerDto { 4 | 5 | @IsNotEmpty() 6 | @IsString() 7 | key: string; 8 | 9 | @IsNotEmpty() 10 | @IsString() 11 | deviceName: string; 12 | 13 | @IsNumber() 14 | @IsNotEmpty() 15 | durationInMinutes: number; 16 | 17 | @IsBoolean() 18 | @IsOptional() 19 | targetState? = false; 20 | } -------------------------------------------------------------------------------- /app/src/services/device/device.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Device } from 'src/models'; 3 | 4 | @Injectable() 5 | export class DeviceService { 6 | 7 | private devices: Array = []; 8 | 9 | public add(device: Device) { 10 | this.remove(device); 11 | this.devices.push(device); 12 | } 13 | 14 | public get(): Array { 15 | return this.devices; 16 | } 17 | 18 | public remove(device: Device) { 19 | this.devices.splice(this.devices.findIndex(d => d.name === device.name)); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /app/src/services/device/device.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { DeviceService } from './device.service'; 3 | 4 | describe('DeviceService', () => { 5 | let service: DeviceService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [DeviceService], 10 | }).compile(); 11 | 12 | service = module.get(DeviceService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /app/src/services/trigger/trigger.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { TriggerService } from './trigger.service'; 3 | 4 | describe('TriggerService', () => { 5 | let service: TriggerService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [TriggerService], 10 | }).compile(); 11 | 12 | service = module.get(TriggerService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /app/dist 3 | /app/node_modules 4 | 5 | # Configuration 6 | .env 7 | 8 | # Secrets 9 | *.secret 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | lerna-debug.log* 18 | 19 | # OS 20 | .DS_Store 21 | 22 | # Tests 23 | /coverage 24 | /.nyc_output 25 | 26 | # IDEs and editors 27 | /.idea 28 | .project 29 | .classpath 30 | .c9/ 31 | *.launch 32 | .settings/ 33 | *.sublime-workspace 34 | 35 | # IDE - VSCode 36 | .vscode/* 37 | !.vscode/settings.json 38 | !.vscode/tasks.json 39 | !.vscode/launch.json 40 | !.vscode/extensions.json -------------------------------------------------------------------------------- /root/etc/cont-init.d/01-envfile: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ "$(ls /var/run/s6/container_environment/ | xargs)" == *"FILE__"* ]]; then 4 | for FILENAME in /var/run/s6/container_environment/*; do 5 | if [[ "${FILENAME##*/}" == "FILE__"* ]]; then 6 | SECRETFILE=$(cat ${FILENAME}) 7 | if [[ -f ${SECRETFILE} ]]; then 8 | FILESTRIP=${FILENAME//FILE__/} 9 | cat ${SECRETFILE} > ${FILESTRIP} 10 | echo "[env-init] ${FILESTRIP##*/} set from ${FILENAME##*/}" 11 | else 12 | echo "[env-init] cannot find secret in ${FILENAME##*/}" 13 | fi 14 | fi 15 | done 16 | fi 17 | -------------------------------------------------------------------------------- /app/src/controllers/trigger/trigger.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { TriggerController } from './trigger.controller'; 3 | 4 | describe('Trigger Controller', () => { 5 | let controller: TriggerController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [TriggerController], 10 | }).compile(); 11 | 12 | controller = module.get(TriggerController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /app/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, HttpModule } from '@nestjs/common'; 2 | import { ScheduleModule } from '@nestjs/schedule'; 3 | import { TriggerController } from './controllers/trigger'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { TriggerService } from './services/trigger'; 6 | import { DeviceService } from './services/device/device.service'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule.forRoot(), 11 | ScheduleModule.forRoot(), 12 | HttpModule 13 | ], 14 | controllers: [TriggerController], 15 | providers: [TriggerService, DeviceService], 16 | }) 17 | export class AppModule { } 18 | -------------------------------------------------------------------------------- /app/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /app/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | import { ConfigService } from '@nestjs/config'; 5 | const helmet = require('helmet') 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule); 9 | const configService: ConfigService = app.get(ConfigService); 10 | const port = configService.get('PORT') || 3000 11 | 12 | app.use(helmet()); 13 | app.useGlobalPipes(new ValidationPipe({ 14 | forbidUnknownValues: true, 15 | disableErrorMessages: true, 16 | })); 17 | app.enableCors(); 18 | 19 | await app.listen(port); 20 | } 21 | bootstrap(); 22 | -------------------------------------------------------------------------------- /app/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3.8" 3 | services: 4 | timer: 5 | image: wiseindy/timer-for-google-assistant 6 | container_name: timer 7 | secrets: 8 | - timer_security_key 9 | - timer_ifttt_event_key 10 | environment: 11 | PORT: 3333 12 | # Secrets can be used by adding FILE__ before each env 13 | FILE__SECURITY_KEY: /run/secrets/timer_security_key 14 | # SECURITY_KEY: ChangeThisToSomethingSecure 15 | FILE__IFTTT_EVENT_KEY: /run/secrets/timer_ifttt_event_key 16 | # IFTTT_EVENT_KEY: xxxxxxxxxxxxxxxxxxxxxx 17 | IFTTT_EVENT_OFF_SUFFIX: _off 18 | IFTTT_EVENT_ON_SUFFIX: _on 19 | ports: 20 | - 3020:3333 21 | restart: unless-stopped 22 | 23 | # If not using secrets, remove this section 24 | secrets: 25 | timer_security_key: 26 | file: ./security_key.secret 27 | timer_ifttt_event_key: 28 | file: ./ifttt_event_key.secret 29 | ... -------------------------------------------------------------------------------- /root/etc/cont-init.d/10-adduser: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv bash 2 | 3 | PUID=${PUID:-911} 4 | PGID=${PGID:-911} 5 | 6 | groupmod -o -g "$PGID" abc 7 | usermod -o -u "$PUID" abc 8 | 9 | echo ' 10 | ------------------------------------- 11 | _ () 12 | | | ___ _ __ 13 | | | / __| | | / \ 14 | | | \__ \ | | | () | 15 | |_| |___/ |_| \__/ 16 | 17 | 18 | Brought to you by linuxserver.io 19 | -------------------------------------' 20 | if [[ -f /donate.txt ]]; then 21 | echo ' 22 | To support the app dev(s) visit:' 23 | cat /donate.txt 24 | fi 25 | echo ' 26 | To support LSIO projects visit: 27 | https://www.linuxserver.io/donate/ 28 | ------------------------------------- 29 | GID/UID 30 | -------------------------------------' 31 | echo " 32 | User uid: $(id -u abc) 33 | User gid: $(id -g abc) 34 | ------------------------------------- 35 | " 36 | chown abc:abc /app 37 | chown abc:abc /config 38 | chown abc:abc /defaults 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright 2020 Wiseindy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /app/src/controllers/trigger/trigger.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Body, BadRequestException, InternalServerErrorException } from '@nestjs/common'; 2 | import { TriggerDto } from 'src/validation'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { TriggerService } from 'src/services/trigger'; 5 | 6 | @Controller('trigger') 7 | export class TriggerController { 8 | 9 | constructor( 10 | private readonly configService: ConfigService, 11 | private readonly triggerService: TriggerService, 12 | ) { } 13 | 14 | @Post() 15 | async trigger(@Body() body: TriggerDto) { 16 | this.validateKey(body.key); 17 | const triggerResult = await this.triggerService.trigger(body.deviceName, body.durationInMinutes, body.targetState); 18 | if (triggerResult) { 19 | if (triggerResult.data) { 20 | return { 21 | success: true, 22 | message: triggerResult.data 23 | } 24 | } 25 | } 26 | throw new InternalServerErrorException('Unable to create trigger') 27 | } 28 | 29 | private validateKey(key: string) { 30 | if (key !== this.configService.get('SECURITY_KEY')) { 31 | throw new BadRequestException(); 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Handle line endings automatically for files detected as text 2 | # and leave all files detected as binary untouched. 3 | * text=auto 4 | 5 | # 6 | # The above will handle all files NOT found below 7 | # 8 | # These files are text and should be normalized (Convert crlf => lf) 9 | Dockerfile 10 | *.css text 11 | *.df text 12 | .dockerignore text 13 | *.htm text 14 | *.html text 15 | *.java text 16 | *.js text 17 | *.json text 18 | *.jsp text 19 | *.jspf text 20 | *.jspx text 21 | *.md text 22 | *.properties text 23 | *.sh text eol=lf 24 | *.tld text 25 | *.txt text 26 | *.tag text 27 | *.tagx text 28 | *.xml text 29 | *.yaml text 30 | *.yml text 31 | 32 | # These files are binary and should be left untouched 33 | # (binary is a macro for -text -diff) 34 | *.class binary 35 | *.dll binary 36 | *.ear binary 37 | *.gif binary 38 | *.ico binary 39 | *.jar binary 40 | *.jpg binary 41 | *.jpeg binary 42 | *.png binary 43 | *.so binary 44 | *.war binary 45 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS cloner 2 | WORKDIR /app 3 | 4 | RUN apk add --no-cache --virtual=build-dependencies --upgrade \ 5 | git && \ 6 | git clone https://github.com/wiseindy/timer-for-google-assistant.git . && \ 7 | rm -rf .git 8 | 9 | FROM node:12 AS builder 10 | WORKDIR /app 11 | COPY --from=cloner /app/app/package*.json ./ 12 | RUN npm install 13 | 14 | COPY --from=cloner /app/app ./ 15 | RUN npm run build 16 | 17 | FROM node:12-slim AS production 18 | ARG NODE_ENV=production 19 | ENV NODE_ENV=${NODE_ENV} 20 | 21 | # set version for s6 overlay 22 | ARG OVERLAY_VERSION="v2.1.0.0" 23 | ARG OVERLAY_ARCH="amd64" 24 | ENV S6_BEHAVIOUR_IF_STAGE2_FAILS=2 25 | 26 | ADD https://github.com/just-containers/s6-overlay/releases/download/${OVERLAY_VERSION}/s6-overlay-${OVERLAY_ARCH}.tar.gz /tmp/ 27 | RUN \ 28 | echo "**** lines from linuxserver.io base image ****" && \ 29 | echo "**** add s6 overlay ****" && \ 30 | tar xfz \ 31 | /tmp/s6-overlay-${OVERLAY_ARCH}.tar.gz -C / && \ 32 | echo "**** create abc user and make our folders ****" && \ 33 | useradd -u 911 -U -d /config -s /bin/false abc && \ 34 | usermod -G users abc && \ 35 | mkdir -p \ 36 | /app \ 37 | /config \ 38 | /defaults && \ 39 | mv /usr/bin/with-contenv /usr/bin/with-contenvb && \ 40 | echo "**** cleanup ****" && \ 41 | apt-get clean && \ 42 | apt-get autoclean -qq -y && \ 43 | apt-get autoremove -qq -y && \ 44 | rm -rf \ 45 | /tmp/* \ 46 | /var/lib/apt/lists/* \ 47 | /var/tmp/* 48 | 49 | WORKDIR /app 50 | COPY --from=cloner /app/app/package*.json ./ 51 | RUN npm install --only=production 52 | 53 | COPY --from=cloner /app/app ./ 54 | COPY --from=cloner /app/root / 55 | COPY --from=builder /app/dist ./dist 56 | 57 | ENTRYPOINT ["/init"] 58 | EXPOSE 3000 59 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timer-for-google-assistant", 3 | "version": "0.1.2", 4 | "description": "Allows you to send commands to Google Assistant that will execute after a certain time.", 5 | "author": "wiseindy", 6 | "license": "MIT", 7 | "scripts": { 8 | "prebuild": "rimraf dist", 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^7.4.2", 24 | "@nestjs/config": "^0.5.0", 25 | "@nestjs/core": "^7.4.2", 26 | "@nestjs/platform-express": "^7.4.2", 27 | "@nestjs/schedule": "^0.4.0", 28 | "class-transformer": "^0.3.1", 29 | "class-validator": "^0.12.2", 30 | "helmet": "^4.0.0", 31 | "reflect-metadata": "^0.1.13", 32 | "rimraf": "^3.0.2", 33 | "rxjs": "^6.6.3" 34 | }, 35 | "devDependencies": { 36 | "@nestjs/cli": "^7.4.1", 37 | "@nestjs/schematics": "^7.1.2", 38 | "@nestjs/testing": "^7.4.2", 39 | "@types/express": "^4.17.7", 40 | "@types/jest": "26.0.9", 41 | "@types/node": "^14.11.2", 42 | "@types/supertest": "^2.0.10", 43 | "@typescript-eslint/eslint-plugin": "^3.8.0", 44 | "@typescript-eslint/parser": "^3.8.0", 45 | "eslint": "^7.6.0", 46 | "eslint-config-prettier": "^6.11.0", 47 | "eslint-plugin-import": "^2.22.0", 48 | "jest": "^26.2.2", 49 | "prettier": "^2.0.5", 50 | "supertest": "^4.0.2", 51 | "ts-jest": "26.1.4", 52 | "ts-loader": "^8.0.2", 53 | "ts-node": "^8.10.2", 54 | "tsconfig-paths": "^3.9.0", 55 | "typescript": "^3.9.7" 56 | }, 57 | "jest": { 58 | "moduleFileExtensions": [ 59 | "js", 60 | "json", 61 | "ts" 62 | ], 63 | "rootDir": "src", 64 | "testRegex": ".spec.ts$", 65 | "transform": { 66 | "^.+\\.(t|j)s$": "ts-jest" 67 | }, 68 | "coverageDirectory": "../coverage", 69 | "testEnvironment": "node" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/src/services/trigger/trigger.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, HttpService, HttpException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { DeviceService } from '../device/device.service'; 4 | import { Device } from 'src/models'; 5 | import { Cron, CronExpression } from '@nestjs/schedule'; 6 | 7 | @Injectable() 8 | export class TriggerService { 9 | 10 | private key: string; 11 | private offEvent: string; 12 | private onEvent: string; 13 | 14 | constructor( 15 | private readonly httpService: HttpService, 16 | private readonly configService: ConfigService, 17 | private readonly deviceService: DeviceService, 18 | ) { 19 | this.key = this.configService.get('IFTTT_EVENT_KEY'); 20 | this.offEvent = this.configService.get('IFTTT_EVENT_OFF_SUFFIX') || '_off'; 21 | this.onEvent = this.configService.get('IFTTT_EVENT_ON_SUFFIX') || '_on'; 22 | } 23 | 24 | public trigger(deviceName: string, durationInMinutes: number, targetState: boolean) { 25 | const timestamp = new Date(); 26 | const device: Device = { 27 | name: deviceName, 28 | added: timestamp, 29 | targetState, 30 | expiry: new Date(timestamp.getTime() + durationInMinutes * 60000), 31 | } 32 | this.deviceService.add(device); 33 | let eventType = this.onEvent; 34 | // If targetState is to turn the device ON (true), set current state to OFF 35 | if (targetState) { 36 | eventType = this.offEvent; 37 | } 38 | return this.iftttTrigger(`${device.name}${eventType}`) 39 | } 40 | 41 | private async iftttTrigger(eventName: string) { 42 | return await this.httpService.get(`https://maker.ifttt.com/trigger/${eventName}/with/key/${this.key}`).toPromise() 43 | .catch(e => { 44 | throw new HttpException(`Unable to communicate with IFTTT: ${e.response.statusText}`, e.response.status) 45 | }); 46 | } 47 | 48 | private checkTime(device: Device): boolean { 49 | if (device) { 50 | const now = new Date(); 51 | if ((device.expiry.getTime() - now.getTime()) <= 0) { 52 | this.deviceService.remove(device); 53 | let eventType = this.onEvent; 54 | if (!device.targetState) { 55 | eventType = this.offEvent; 56 | } 57 | this.iftttTrigger(`${device.name}${eventType}`); 58 | } 59 | } 60 | return false; 61 | } 62 | 63 | @Cron(CronExpression.EVERY_10_SECONDS) 64 | protected handleCron() { 65 | for (const device of this.deviceService.get()) { 66 | this.checkTime(device); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Timer for Google Assistant](/app/assets/timer-for-google-assistant.png?raw=true "Timer for Google Assistant") 2 | 3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

17 | 18 | ## Description 19 | 20 | Wouldn't it be cool if you could say stuff like: 21 | 22 | * *"Hey Google, turn off the lights after 10 minutes"* 23 | * *"Hey Google, turn on the fan for 25 minutes"* 24 | * *"Hey Google, turn off <device> after <x> minutes"* 25 | 26 | Well, this project makes it possible. **Timer for Google Assistant** allows you to send commands to Google Assistant that will execute after a certain time. 27 | 28 | ## How does it work? 29 | 30 | **Timer for Google Assistant** provides a simple API with which you can schedule commands. See [API Reference](#api-reference) below. 31 | 32 | This application is built using [NestJS - A progressive Node.js framework](https://nestjs.com/). 33 | It uses [IFTTT](https://ifttt.com/) to communicate with Google Assistant and your smart device. 34 | 35 | There are 2 ways to set it up: 36 | 37 | 1. [Install using Docker](#install-using-docker) (recommended) 38 | 2. [Manual installation without Docker](#manual-installation-without-docker) 39 | 40 | ## API Reference 41 | 42 | ### Usage 43 | 44 | `POST /trigger` 45 | 46 | ### Request body 47 | 48 | Content type: `application/json` 49 | | Name | Type | Required | Default value | Description | 50 | |:------------------------|:----------|:---------|:--------------|:-------------------------------------------------------------------------------------------------------| 51 | | *key* | `string` | **Yes** | - | This key can be any value, however, it should the match the key specified while setting up the server. | 52 | | *durationInMinutes* | `number` | **Yes** | - | Number of minutes after which the action should be triggered. | 53 | | *deviceName* | `string` | **Yes** | - | Name of the target device. | 54 | | *targetState* | `boolean` | No | `false` | What should the state of the device be *after* firing the event? `true` = ON; `false` = OFF | 55 | 56 | #### Example 57 | 58 | Making a `POST` request with the parameters below will set the `lights` to `OFF` after `20` minutes. 59 | 60 | ``` json 61 | { 62 | "key":"ChangeThisToSomethingSecure", 63 | "durationInMinutes":20, 64 | "deviceName":"lights", 65 | "targetState":false 66 | } 67 | ``` 68 | 69 | #### A note on the `targetState` parameter 70 | 71 | If `targetState` is set to `false` (i.e., OFF), the device will be first turned `ON` upon receiving this command. After the specified time has elapsed, the device will be turned `OFF`. This is an optional parameter and by default, `targetState` is `false`. 72 | 73 | If `targetState` is set to `true` (i.e., ON), it will do the opposite. The device will be first turned `OFF` upon receiving this command. After the specified time has elapsed, the device will be turned `ON`. 74 | 75 | 76 | ## Install using Docker 77 | 78 | **Timer for Google Assistant** is available as a [Docker image](https://hub.docker.com/r/wiseindy/timer-for-google-assistant). 79 | 80 | ### Prerequisites 81 | 82 | * An internet facing server (so that IFTTT can make requests to your server) 83 | * [Docker](https://www.docker.com/) installed on this server. 84 | 85 | #### Running with `docker` 86 | 87 | Run the following command on your server to get this application up and running. The application runs on port `3000` internally. The command below makes the API available externally on port `3020`. 88 | 89 | [For information on what the environment variables mean, see this section below.](#create-a-env-file-in-the-root-directory-sample-values-can-be-seen-in-envexample) 90 | 91 | ```bash 92 | docker run \ 93 | -e SECURITY_KEY="ChangeThisToSomethingSecure" \ 94 | -e IFTTT_EVENT_OFF_SUFFIX="_off" \ 95 | -e IFTTT_EVENT_ON_SUFFIX="_on" \ 96 | -e IFTTT_EVENT_KEY="xxxxxxxxxxxxxxxxxxxxxx" \ 97 | -p 3020:3000 wiseindy/timer-for-google-assistant 98 | ``` 99 | 100 | You can also specify the environment variables in a separate file, for example, `my_env_data`. 101 | 102 | ``` 103 | SECURITY_KEY=ChangeThisToSomethingSecure 104 | IFTTT_EVENT_OFF_SUFFIX=_off 105 | IFTTT_EVENT_ON_SUFFIX=_on 106 | IFTTT_EVENT_KEY=xxxxxxxxxxxxxxxxxxxxxx 107 | ``` 108 | 109 | Now you can use a simpler command: 110 | 111 | ```bash 112 | docker run --env-file ./my_env_data -p 3020:3000 wiseindy/timer-for-google-assistant 113 | ``` 114 | 115 | #### Running with `docker-compose` 116 | 117 | If using docker compose, download the [sample `docker-compose.yml` in this project](docker-compose.yml). 118 | 119 | * **Add your keys to `docker-compose.yml`** 120 | \ 121 | Create two files `ifttt_event_key.secret` and `security_key.secret` which contain your IFTTT event key and security key respectively. 122 | \ 123 | When deploying with docker, any environment variable can be prefixed with `FILE__` (two underscores) to be used with docker secrets (recommended for `SECURITY_KEY` and `IFTTT_EVENT_KEY`). 124 | 125 | ```yaml 126 | ... 127 | environment: 128 | FILE__SECURITY_KEY: /run/secrets/timer_security_key 129 | FILE__IFTTT_EVENT_KEY: /run/secrets/timer_ifttt_event_key 130 | IFTTT_EVENT_OFF_SUFFIX: _off 131 | IFTTT_EVENT_ON_SUFFIX: _on 132 | 133 | secrets: 134 | timer_security_key: 135 | file: ./security_key.secret 136 | timer_ifttt_event_key: 137 | file: ./ifttt_event_key.secret 138 | ... 139 | ``` 140 | 141 | * **[NOT RECOMMENDED] Another way to add your keys to `docker-compose.yml`** 142 | \ 143 | An alternative to using Docker secrets is to directly include the keys in your `docker-compose.yml` file. The above technique of using secrets is more secure and recommended over this method. 144 | \ 145 | Instead of using secrets in separate files, you can directly set the environment variables under the `environment` section to appropriate values. 146 | 147 | ```yaml 148 | ... 149 | environment: 150 | SECURITY_KEY: ChangeThisToSomethingSecure 151 | IFTTT_EVENT_KEY: xxxxxxxxxxxxxxxxxxxxxx 152 | IFTTT_EVENT_OFF_SUFFIX: _off 153 | IFTTT_EVENT_ON_SUFFIX: _on 154 | ... 155 | ``` 156 | 157 | [For information on what the environment variables mean, see this section below.](#create-a-env-file-in-the-root-directory-sample-values-can-be-seen-in-envexample) 158 | 159 | To start the server, run: 160 | 161 | ```bash 162 | docker-compose up -d 163 | ``` 164 | 165 | To stop, run: 166 | 167 | ```bash 168 | docker-compose down 169 | ``` 170 | 171 | Next, you'll need to set up triggers and actions in IFTTT. [Jump to section](#integrate-with-ifttt). 172 | 173 | ## Manual installation without Docker 174 | 175 | ### Prerequisites 176 | 177 | * An internet facing web server (so that IFTTT can make requests to your server) 178 | * [NodeJS](https://nodejs.org/en/) 12.18.3 or higher (lower versions may also work, but I haven't tested it) 179 | * An [IFTTT](https://ifttt.com/) account 180 | 181 | ## Installation 182 | 183 | ### Clone this repository 184 | 185 | ``` bash 186 | git clone https://github.com/wiseindy/timer-for-google-assistant.git 187 | ``` 188 | 189 | ### Enter the directory and run `npm install` 190 | 191 | ``` bash 192 | cd timer-for-google-assistant/app 193 | npm install 194 | ``` 195 | 196 | ### Create a `.env` file in the `/app` directory. Sample values can be seen in `/app/.env.example` 197 | 198 | ``` bash 199 | PORT=3020 200 | SECURITY_KEY=ChangeThisToSomethingSecure 201 | IFTTT_EVENT_OFF_SUFFIX=_off 202 | IFTTT_EVENT_ON_SUFFIX=_on 203 | IFTTT_EVENT_KEY=xxxxxxxxxxxxxxxxxxxxxx 204 | ``` 205 | 206 | * `PORT` : (DEFAULT: `3000`, if not specified) This is the port number the application will use. You'll need to add this exception to your firewall rule. You can also use a reverse proxy. If you're using the docker image, this is the port used by the app, not the one exposed by the container; remember to change your port value accordingly in the docker command or your docker-compose. 207 | * `SECURITY_KEY` : (REQUIRED) Set this to a **unique** string and **do not share it with anyone**. 208 | ``` diff 209 | - IMPORTANT! Make sure you change your SECURITY KEY to something secure and DO NOT use the default value. 210 | ``` 211 | * `IFTTT_EVENT_OFF_SUFFIX` : (REQUIRED) The suffix for the "off" action in IFTTT. For more details, please view [Integrate with IFTTT](#integrate-with-ifttt) section below. 212 | * `IFTTT_EVENT_ON_SUFFIX` : (REQUIRED) The suffix for the "on" action in IFTTT. For more details, please view [Integrate with IFTTT](#integrate-with-ifttt) section below. 213 | * `IFTTT_EVENT_KEY` : (REQUIRED) You can get your IFTTT key from [https://ifttt.com/maker_webhooks](https://ifttt.com/maker_webhooks). Click the **Documentation** button at the top to get your key.\ 214 | \ 215 | ![IFTTT Webhooks page screenshot](/app/assets/ifttt_maker_webhooks.png?raw=true "IFTTT Webhooks") 216 | \ 217 | ![IFTTT Webhooks key page screenshot](/app/assets/ifttt_maker_webhooks_key.png?raw=true "IFTTT Webhooks key") 218 | 219 | ### Build the application 220 | 221 | ```bash 222 | npm run build 223 | ``` 224 | 225 | ### Start the application 226 | 227 | ``` bash 228 | npm run start:prod 229 | ``` 230 | 231 | For more options, view [Running the app](#running-the-app) section. 232 | 233 | --- 234 | 235 | ### Integrate with IFTTT 236 | 237 | You will be creating two actions in IFTTT; one to turn off the device and another to turn it on. 238 | 239 | Both these actions/applets will work by receiving a web request and triggering the device ON or OFF (using IFTTT's Webhooks feature). 240 | 241 | #### Set up webhooks 242 | 243 | 1. Login to your [IFTTT](https://ifttt.com/) and create a new applet. For the `this` trigger, choose `Webhooks`.\ 244 | \ 245 | ![IFTTT create a new applet](/app/assets/ifttt_webhooks_create.gif?raw=true "IFTTT create a new applet") 246 | 247 | 2. Follow a consistent naming scheme for all events. Use the correct suffixes for your device events as specified in the `IFTTT_EVENT_OFF_SUFFIX` and `IFTTT_EVENT_ON_SUFFIX` parameters in the `.env` file above.\ 248 | \ 249 | For example, if the device is a smart light, use `lights_off` and `lights_on` as the event names to turn the light OFF and ON respectively. DO NOT use inconsistent names like `lights_off` and `LIGHT_ON`.\ 250 | \ 251 | **Make sure you follow the SAME naming scheme for ALL events (they're case sensitive)**. \ 252 | \ 253 | ![IFTTT name your event](/app/assets/ifttt_webhooks_trigger_event_name.gif?raw=true "IFTTT name your event") 254 | 255 | 3. Next, choose your smart device from the list and select the action you'd like to carry out.\ 256 | \ 257 | ![IFTTT set event target](/app/assets/ifttt_webhooks_trigger_event_target.gif?raw=true "IFTTT set event target") 258 | 259 | 4. Repeat the above steps to create the `ON` trigger for your device. 260 | 261 | #### Configure IFTTT to receive commands from Google Assistant and forward to your server 262 | 263 | 1. Create a new applet/action in IFTTT. For the `this` trigger, choose `Google Assistant`.\ 264 | \ 265 | ![IFTTT create a new Google Assistant applet](/app/assets/ifttt_webhooks_create_google_assistant.gif?raw=true "IFTTT create a new Google Assistant applet") 266 | 267 | 2. Select **Say a phrase with a number**\ 268 | \ 269 | ![IFTTT Google Assistant applet](/app/assets/ifttt_google_assistant.gif?raw=true "IFTTT Google Assistant applet") 270 | 271 | 3. Set your trigger phrase and the response. In this example, I want Google Assistant to turn on the lights and then turn it off after X minutes. Use `#` to specify where you'll say the number of minutes.\ 272 | \ 273 | ![IFTTT Google Assistant number trigger](/app/assets/ifttt_google_assistant_number_trigger.png?raw=true "IFTTT Google Assistant number trigger") 274 | 275 | 4. For the `that` action in your applet, select `Webhooks`. This will be used to make a web request to your server.\ 276 | \ 277 | ![IFTTT set target to webhooks web request](/app/assets/ifttt_google_assistant_trigger_event_target.gif?raw=true "IFTTT set target to webhooks web request") 278 | 279 | 5. Fill the action fields with the following values. For more information, refer to the [API Reference](#api-reference) below.\ 280 | \ 281 | For the `URL` field, type in the domain/IP of your webserver running this application.\ 282 | The API endpoint that handles requests is `/trigger`.\ 283 | Set the web request method to `POST`.\ 284 | Select `application/json` as Content Type.\ 285 | For the Body parameter, specify the following values:\ 286 | \ 287 | ![IFTTT web request action](/app/assets/ifttt_webhooks_make_http_request.png?raw=true "IFTTT web request action") 288 | 289 | Sample request body: 290 | 291 | ``` 292 | { 293 | "key":"ChangeThisToSomethingSecure", 294 | "durationInMinutes":{{NumberField}}, 295 | "deviceName":"lights" 296 | } 297 | ``` 298 | 299 | * Make sure to use the same `key` that you specified in the `.env` file. 300 | * The device name `lights` should match the name used to create OFF/ON events: `lights_off` and `lights_on`. These values are case-sensitive. 301 | 302 | --- 303 | 304 | ## Running the app 305 | 306 | ``` bash 307 | # development 308 | npm run start 309 | 310 | # watch mode 311 | npm run start:dev 312 | 313 | # production mode 314 | npm run start:prod 315 | ``` 316 | 317 | That's it! Try saying *"Hey Google, turn on the lights for 2 minutes."* and if everything is setup right, Google Assistant will turn ON your lights and after two minutes, it should turn them OFF. 318 | 319 | ## Test 320 | 321 | ``` bash 322 | # unit tests 323 | npm run test 324 | 325 | # e2e tests 326 | npm run test:e2e 327 | 328 | # test coverage 329 | npm run test:cov 330 | ``` 331 | 332 | ## Support 333 | 334 | Buy Me A Coffee 335 | 336 | ## Author 337 | 338 | * Website - [https://wiseindy.com](https://wiseindy.com/) 339 | * Twitter - [@wiseindy](https://twitter.com/wiseindy) 340 | 341 | ## License 342 | 343 | **Timer for Google Assistant** is [MIT licensed](LICENSE). 344 | 345 | All trademarks are the property of their respective owners. 346 | --------------------------------------------------------------------------------