Skip to content

Commit

Permalink
feat(shell): added shell integration
Browse files Browse the repository at this point in the history
Allows execution and parsing of any shell command on the local machine,
the data can then be passed as a sensor.
  • Loading branch information
mKeRix committed Feb 1, 2020
1 parent 9f93de9 commit fdf759a
Show file tree
Hide file tree
Showing 6 changed files with 276 additions and 0 deletions.
12 changes: 12 additions & 0 deletions config/test.yml
Expand Up @@ -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'
2 changes: 2 additions & 0 deletions src/config/definitions/default.ts
Expand Up @@ -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();
Expand All @@ -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();
}

Expand Down
13 changes: 13 additions & 0 deletions 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;
}
17 changes: 17 additions & 0 deletions 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]
};
}
}
144 changes: 144 additions & 0 deletions 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>(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();
});
});
88 changes: 88 additions & 0 deletions 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<string> {
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<EntityCustomization<any>> = [
{
for: SensorConfig,
overrides: {
deviceClass,
unitOfMeasurement,
icon
}
}
];
return this.entitiesService.add(
new Sensor(id, name),
customizations
) as Sensor;
}
}

0 comments on commit fdf759a

Please sign in to comment.