From fdf759ad072e37e26cef1c39a892d281cda8f61c Mon Sep 17 00:00:00 2001 From: Heiko Rothe Date: Sat, 1 Feb 2020 22:56:34 +0100 Subject: [PATCH] feat(shell): added shell integration Allows execution and parsing of any shell command on the local machine, the data can then be passed as a sensor. --- config/test.yml | 12 ++ src/config/definitions/default.ts | 2 + src/integrations/shell/shell.config.ts | 13 ++ src/integrations/shell/shell.module.ts | 17 +++ src/integrations/shell/shell.service.spec.ts | 144 +++++++++++++++++++ src/integrations/shell/shell.service.ts | 88 ++++++++++++ 6 files changed, 276 insertions(+) create mode 100644 src/integrations/shell/shell.config.ts create mode 100644 src/integrations/shell/shell.module.ts create mode 100644 src/integrations/shell/shell.service.spec.ts create mode 100644 src/integrations/shell/shell.service.ts diff --git a/config/test.yml b/config/test.yml index 4aeade0..7295687 100644 --- a/config/test.yml +++ b/config/test.yml @@ -13,3 +13,15 @@ gpio: - name: Radar pin: 24 deviceClass: motion +shell: + sensors: + - name: Simple Test + command: echo test + cron: '* * * * *' + - name: Regex Test + command: echo 'test 123' + cron: '* * * * */2' + regex: '[1-9]+' + deviceClass: 'timestamp' + icon: 'mdi:test' + unitOfMeasurement: 'tests' diff --git a/src/config/definitions/default.ts b/src/config/definitions/default.ts index 1658d4e..9f72ac5 100644 --- a/src/config/definitions/default.ts +++ b/src/config/definitions/default.ts @@ -6,6 +6,7 @@ import { OmronD6tConfig } from '../../integrations/omron-d6t/omron-d6t.config'; import { GridEyeConfig } from '../../integrations/grid-eye/grid-eye.config'; import { BluetoothClassicConfig } from '../../integrations/bluetooth-classic/bluetooth-classic.config'; import { GpioConfig } from '../../integrations/gpio/gpio.config'; +import { ShellConfig } from '../../integrations/shell/shell.config'; export class AppConfig { global: GlobalConfig = new GlobalConfig(); @@ -15,6 +16,7 @@ export class AppConfig { omronD6t: OmronD6tConfig = new OmronD6tConfig(); gridEye: GridEyeConfig = new GridEyeConfig(); gpio: GpioConfig = new GpioConfig(); + shell: ShellConfig = new ShellConfig(); homeAssistant: HomeAssistantConfig = new HomeAssistantConfig(); } diff --git a/src/integrations/shell/shell.config.ts b/src/integrations/shell/shell.config.ts new file mode 100644 index 0000000..0c060bb --- /dev/null +++ b/src/integrations/shell/shell.config.ts @@ -0,0 +1,13 @@ +export class ShellConfig { + sensors: ShellSensorOptions[] = []; +} + +class ShellSensorOptions { + name: string; + command: string; + regex?: string; + cron: string; + icon?: string; + unitOfMeasurement?: string; + deviceClass?: string; +} diff --git a/src/integrations/shell/shell.module.ts b/src/integrations/shell/shell.module.ts new file mode 100644 index 0000000..5f8a911 --- /dev/null +++ b/src/integrations/shell/shell.module.ts @@ -0,0 +1,17 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { ShellService } from './shell.service'; +import { ConfigModule } from '../../config/config.module'; +import { EntitiesModule } from '../../entities/entities.module'; + +@Module({ + providers: [ShellService] +}) +export default class ShellModule { + static forRoot(): DynamicModule { + return { + module: ShellModule, + imports: [ConfigModule, EntitiesModule], + providers: [ShellService] + }; + } +} diff --git a/src/integrations/shell/shell.service.spec.ts b/src/integrations/shell/shell.service.spec.ts new file mode 100644 index 0000000..b715586 --- /dev/null +++ b/src/integrations/shell/shell.service.spec.ts @@ -0,0 +1,144 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ShellService } from './shell.service'; +import { ConfigModule } from '../../config/config.module'; +import { EntitiesModule } from '../../entities/entities.module'; +import { EntitiesService } from '../../entities/entities.service'; +import { ClusterService } from '../../cluster/cluster.service'; +import { Sensor } from '../../entities/sensor'; +import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; +import { SensorConfig } from '../home-assistant/sensor-config'; +import { CronJob } from 'cron'; +import * as util from 'util'; + +jest.mock('cron'); +jest.mock('util', () => ({ + ...jest.requireActual('util'), + promisify: jest.fn() +})); + +describe('ShellService', () => { + let service: ShellService; + const entitiesService = { + add: jest.fn() + }; + const schedulerRegistry = { + addCronJob: jest.fn() + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule, EntitiesModule, ScheduleModule.forRoot()], + providers: [ShellService] + }) + .overrideProvider(EntitiesService) + .useValue(entitiesService) + .overrideProvider(ClusterService) + .useValue({}) + .overrideProvider(SchedulerRegistry) + .useValue(schedulerRegistry) + .compile(); + + service = module.get(ShellService); + }); + + it('should register sensors on bootstrap', () => { + service.onApplicationBootstrap(); + + expect(entitiesService.add).toHaveBeenCalledTimes(2); + expect(entitiesService.add).toHaveBeenCalledWith( + new Sensor('shell-simple-test', 'Simple Test'), + expect.any(Array) + ); + expect(entitiesService.add).toHaveBeenCalledWith( + new Sensor('shell-regex-test', 'Regex Test'), + expect.any(Array) + ); + }); + + it('should pass on entity customizations', () => { + service.onApplicationBootstrap(); + + expect(entitiesService.add.mock.calls[1][1]).toContainEqual({ + for: SensorConfig, + overrides: { + deviceClass: 'timestamp', + icon: 'mdi:test', + unitOfMeasurement: 'tests' + } + }); + }); + + it('should register cronjobs for the configured commands', () => { + service.onApplicationBootstrap(); + + expect(CronJob).toHaveBeenCalledTimes(2); + expect(CronJob).toHaveBeenCalledWith('* * * * *', expect.any(Function)); + expect(CronJob).toHaveBeenCalledWith('* * * * */2', expect.any(Function)); + + expect(schedulerRegistry.addCronJob).toHaveBeenCalledTimes(2); + expect(schedulerRegistry.addCronJob).toHaveBeenCalledWith( + 'shell-simple-test', + expect.any(CronJob) + ); + expect(schedulerRegistry.addCronJob).toHaveBeenCalledWith( + 'shell-regex-test', + expect.any(CronJob) + ); + }); + + it('should start cronjobs once they are registered', () => { + service.onApplicationBootstrap(); + expect(CronJob.mock.instances[0].start).toHaveBeenCalled(); + }); + + it('should set the sensor state using the configured command', async () => { + const sensor = new Sensor('test-sensor', 'Test'); + entitiesService.add.mockReturnValue(sensor); + jest.spyOn(service, 'executeCommand').mockResolvedValue('42'); + + service.onApplicationBootstrap(); + await CronJob.mock.calls[0][1](); + + expect(sensor.state).toBe('42'); + }); + + it('should return trimmed output for commands without regex', async () => { + jest.spyOn(util, 'promisify').mockImplementation(() => { + return jest.fn().mockResolvedValue({ stdout: '1234\n' }); + }); + + expect(await service.executeCommand('echo 1234')).toBe('1234'); + }); + + it('should return matched string if regex was provided', async () => { + jest.spyOn(util, 'promisify').mockImplementation(() => { + return jest.fn().mockResolvedValue({ stdout: 'test 1234\n' }); + }); + + expect(await service.executeCommand('echo "test 1234"', /[0-9]+/g)).toBe( + '1234' + ); + }); + + it('should return first capture group if available', async () => { + jest.spyOn(util, 'promisify').mockImplementation(() => { + return jest.fn().mockResolvedValue({ stdout: 'test 1234\n' }); + }); + + expect( + await service.executeCommand('echo "test 1234"', /test ([0-9]+)/g) + ).toBe('1234'); + }); + + it('should return undefined if no match was found', async () => { + jest.spyOn(util, 'promisify').mockImplementation(() => { + return jest.fn().mockResolvedValue({ stdout: 'test 1234\n' }); + }); + + expect( + await service.executeCommand('echo "test 1234"', /([0-9]+) test/g) + ).toBeUndefined(); + }); +}); diff --git a/src/integrations/shell/shell.service.ts b/src/integrations/shell/shell.service.ts new file mode 100644 index 0000000..40342c9 --- /dev/null +++ b/src/integrations/shell/shell.service.ts @@ -0,0 +1,88 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { ConfigService } from '../../config/config.service'; +import { EntitiesService } from '../../entities/entities.service'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import * as util from 'util'; +import { exec } from 'child_process'; +import { ShellConfig } from './shell.config'; +import { makeId } from '../../util/id'; +import { Sensor } from '../../entities/sensor'; +import { EntityCustomization } from '../../entities/entity-customization.interface'; +import { SensorConfig } from '../home-assistant/sensor-config'; +import { CronJob } from 'cron'; + +@Injectable() +export class ShellService implements OnApplicationBootstrap { + private readonly config: ShellConfig; + private readonly logger: Logger; + + constructor( + private readonly configService: ConfigService, + private readonly entitiesService: EntitiesService, + private readonly schedulerRegistry: SchedulerRegistry + ) { + this.config = this.configService.get('shell'); + this.logger = new Logger(ShellService.name); + } + + onApplicationBootstrap(): void { + this.config.sensors.forEach(sensorOptions => { + const sensor = this.createSensor( + sensorOptions.name, + sensorOptions.deviceClass, + sensorOptions.unitOfMeasurement, + sensorOptions.icon + ); + const regex = sensorOptions.regex + ? new RegExp(sensorOptions.regex) + : undefined; + const job = new CronJob(sensorOptions.cron, async () => { + sensor.state = await this.executeCommand(sensorOptions.command, regex); + }); + + this.schedulerRegistry.addCronJob( + makeId(`shell ${sensorOptions.name}`), + job + ); + job.start(); + }); + } + + async executeCommand(command: string, regex?: RegExp): Promise { + const execPromise = util.promisify(exec); + const output = await execPromise(command); + this.logger.debug( + `${command} returned stdout:\n${output.stdout}\nstderr:\n${output.stderr}` + ); + + if (regex) { + const results = regex.exec(output.stdout); + return results ? results[1] || results[0] : undefined; + } else { + return output.stdout.trim(); + } + } + + protected createSensor( + name: string, + deviceClass?: string, + unitOfMeasurement?: string, + icon?: string + ): Sensor { + const id = makeId(`shell ${name}`); + const customizations: Array> = [ + { + for: SensorConfig, + overrides: { + deviceClass, + unitOfMeasurement, + icon + } + } + ]; + return this.entitiesService.add( + new Sensor(id, name), + customizations + ) as Sensor; + } +}