Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Showing
6 changed files
with
276 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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] | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |