diff --git a/config/default.ts b/config/default.ts index 9d9358a..bbaa07d 100644 --- a/config/default.ts +++ b/config/default.ts @@ -4,12 +4,14 @@ import {ClusterConfig} from '../src/cluster/cluster.config'; import {GlobalConfig} from '../src/config/global.config'; import {HomeAssistantConfig} from '../src/integrations/home-assistant/home-assistant.config'; import {OmronD6tConfig} from '../src/integrations/omron-d6t/omron-d6t.config'; +import {GridEyeConfig} from '../src/integrations/grid-eye/grid-eye.config'; const config: AppConfig = { global: new GlobalConfig(), cluster: new ClusterConfig(), bluetoothLowEnergy: new BluetoothLowEnergyConfig(), omronD6t: new OmronD6tConfig(), + gridEye: new GridEyeConfig(), homeAssistant: new HomeAssistantConfig(), }; diff --git a/src/config/app.config.ts b/src/config/app.config.ts index 0277fa3..b931bd6 100644 --- a/src/config/app.config.ts +++ b/src/config/app.config.ts @@ -3,11 +3,13 @@ import { ClusterConfig } from '../cluster/cluster.config'; import { BluetoothLowEnergyConfig } from '../integrations/bluetooth-low-energy/bluetooth-low-energy.config'; import { HomeAssistantConfig } from '../integrations/home-assistant/home-assistant.config'; import { OmronD6tConfig } from '../integrations/omron-d6t/omron-d6t.config'; +import { GridEyeConfig } from '../integrations/grid-eye/grid-eye.config'; export default interface AppConfig { global: GlobalConfig; cluster: ClusterConfig; bluetoothLowEnergy: BluetoothLowEnergyConfig; omronD6t: OmronD6tConfig; + gridEye: GridEyeConfig; homeAssistant: HomeAssistantConfig; } diff --git a/src/integrations/grid-eye/grid-eye.config.ts b/src/integrations/grid-eye/grid-eye.config.ts new file mode 100644 index 0000000..5e47d25 --- /dev/null +++ b/src/integrations/grid-eye/grid-eye.config.ts @@ -0,0 +1,5 @@ +export class GridEyeConfig { + busNumber: number = 1; + address: number = 0x69; + deltaThreshold: number = 2; +} diff --git a/src/integrations/grid-eye/grid-eye.module.ts b/src/integrations/grid-eye/grid-eye.module.ts new file mode 100644 index 0000000..e98a1a3 --- /dev/null +++ b/src/integrations/grid-eye/grid-eye.module.ts @@ -0,0 +1,16 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { GridEyeService } from './grid-eye.service'; +import { EntitiesModule } from '../../entities/entities.module'; +import { ConfigModule } from '../../config/config.module'; +import { ScheduleModule } from '@nestjs/schedule'; + +@Module({}) +export default class GridEyeModule { + static forRoot(): DynamicModule { + return { + module: GridEyeModule, + imports: [EntitiesModule, ConfigModule, ScheduleModule.forRoot()], + providers: [GridEyeService] + }; + } +} diff --git a/src/integrations/grid-eye/grid-eye.service.spec.ts b/src/integrations/grid-eye/grid-eye.service.spec.ts new file mode 100644 index 0000000..9e56801 --- /dev/null +++ b/src/integrations/grid-eye/grid-eye.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GridEyeService } from './grid-eye.service'; + +describe('GridEyeService', () => { + let service: GridEyeService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [GridEyeService] + }).compile(); + + service = module.get(GridEyeService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/integrations/grid-eye/grid-eye.service.ts b/src/integrations/grid-eye/grid-eye.service.ts new file mode 100644 index 0000000..15d24ed --- /dev/null +++ b/src/integrations/grid-eye/grid-eye.service.ts @@ -0,0 +1,105 @@ +/* tslint:disable:no-bitwise */ +import { + Injectable, + OnApplicationBootstrap, + OnApplicationShutdown +} from '@nestjs/common'; +import i2cBus, { PromisifiedBus } from 'i2c-bus'; +import { Entity } from '../../entities/entity.entity'; +import { EntitiesService } from '../../entities/entities.service'; +import { Sensor } from '../../entities/sensor.entity'; +import * as math from 'mathjs'; +import { Interval } from '@nestjs/schedule'; +import { ThermopileOccupancySensor } from '../../util/thermopile/thermopile-occupancy.sensor'; +import { GridEyeConfig } from './grid-eye.config'; +import { ConfigService } from '../../config/config.service'; + +const TEMPERATURE_REGISTER_START = 0x80; +const FRAMERATE_REGISTER = 0x02; + +@Injectable() +export class GridEyeService extends ThermopileOccupancySensor + implements OnApplicationBootstrap, OnApplicationShutdown { + private readonly config: GridEyeConfig; + private i2cBus: PromisifiedBus; + private sensor: Entity; + + constructor( + private readonly entitiesService: EntitiesService, + private readonly configService: ConfigService + ) { + super(); + this.config = this.configService.get('gridEye'); + } + + async onApplicationBootstrap(): Promise { + this.i2cBus = await i2cBus.openPromisified(this.config.busNumber); + this.setRegister(FRAMERATE_REGISTER, 1); // set framerate to 1 FPS -> less noise + this.sensor = this.entitiesService.add( + new Sensor('grideye_occupancy_count', 'GridEYE Occupancy Count') + ); + } + + async onApplicationShutdown(signal?: string): Promise { + return this.i2cBus.close(); + } + + @Interval(1000) + async updateState(): Promise { + const coordinates = await this.getCoordinates(this.config.deltaThreshold); + + this.sensor.state = coordinates.length; + this.sensor.attributes.coordinates = coordinates; + } + + async getPixelTemperatures(): Promise { + const temperatures = []; + for (let i = 0; i < 64; i++) { + temperatures.push(await this.getPixelTemperature(i)); + } + + return math.reshape(temperatures, [8, 8]) as number[][]; + } + + async getPixelTemperature(pixelAddr: number): Promise { + const pixelLowRegister = TEMPERATURE_REGISTER_START + 2 * pixelAddr; + let temperature = await this.getRegister(pixelLowRegister, 2); + + if (temperature & (1 << 11)) { + temperature &= ~(1 << 11); + temperature = temperature * -1; + } + + return temperature * 0.25; + } + + async getRegister(register: number, length: number): Promise { + const commandBuffer = Buffer.alloc(1); + commandBuffer.writeUInt8(register, 0); + const resultBuffer = Buffer.alloc(length); + + await this.i2cBus.i2cWrite( + this.config.address, + commandBuffer.length, + commandBuffer + ); + await this.i2cBus.i2cRead(this.config.address, length, resultBuffer); + + const lsb = resultBuffer.readUInt8(0); + const msb = resultBuffer.readUInt8(1); + + return (msb << 8) | lsb; + } + + async setRegister(register: number, value: number): Promise { + const commandBuffer = Buffer.alloc(2); + commandBuffer.writeUInt8(register, 0); + commandBuffer.writeUInt8(value, 1); + + await this.i2cBus.i2cWrite( + this.config.address, + commandBuffer.length, + commandBuffer + ); + } +} diff --git a/src/integrations/omron-d6t/omron-d6t.service.ts b/src/integrations/omron-d6t/omron-d6t.service.ts index 7324051..7d9cfb1 100644 --- a/src/integrations/omron-d6t/omron-d6t.service.ts +++ b/src/integrations/omron-d6t/omron-d6t.service.ts @@ -11,17 +11,16 @@ import { OmronD6tConfig } from './omron-d6t.config'; import i2cBus, { PromisifiedBus } from 'i2c-bus'; import { Interval } from '@nestjs/schedule'; import * as math from 'mathjs'; -import { Pixel } from './pixel.entity'; -import { Cluster } from './cluster.entity'; import { Sensor } from '../../entities/sensor.entity'; import { Entity } from '../../entities/entity.entity'; import { I2CError } from './i2c.error'; import { SensorConfig } from '../home-assistant/sensor-config'; +import { ThermopileOccupancySensor } from '../../util/thermopile/thermopile-occupancy.sensor'; const TEMPERATURE_COMMAND = 0x4c; @Injectable() -export class OmronD6tService +export class OmronD6tService extends ThermopileOccupancySensor implements OnApplicationBootstrap, OnApplicationShutdown { private readonly config: OmronD6tConfig; private i2cBus: PromisifiedBus; @@ -32,6 +31,7 @@ export class OmronD6tService private readonly entitiesService: EntitiesService, private readonly configService: ConfigService ) { + super(); this.config = this.configService.get('omronD6t'); this.logger = new Logger(OmronD6tService.name); } @@ -56,19 +56,12 @@ export class OmronD6tService } @Interval(250) - async updateState() { + async updateState(): Promise { try { - const temperatures = await this.getPixelTemperatures(); - const relevantPixels = this.findRelevantPixels( - temperatures as number[][] - ); - const clusters = this.clusterPixels(relevantPixels); - - this.sensor.state = clusters.length; - this.sensor.attributes.coordinates = clusters.map(cluster => [ - cluster.center.x, - cluster.center.y - ]); + const coordinates = await this.getCoordinates(this.config.deltaThreshold); + + this.sensor.state = coordinates.length; + this.sensor.attributes.coordinates = coordinates; } catch (e) { if (e instanceof I2CError) { this.logger.debug(`Error during I2C communication: ${e.message}`); @@ -81,7 +74,7 @@ export class OmronD6tService } } - async getPixelTemperatures() { + async getPixelTemperatures(): Promise { const commandBuffer = Buffer.alloc(1); const resultBuffer = Buffer.alloc(35); @@ -108,10 +101,10 @@ export class OmronD6tService pixelTemperatures.push(temperature); } - return math.reshape(pixelTemperatures, [4, 4]); + return math.reshape(pixelTemperatures, [4, 4]) as number[][]; } - checkPEC(buffer: Buffer, pecIndex: number) { + checkPEC(buffer: Buffer, pecIndex: number): boolean { let crc = this.calculateCRC(0x15); for (let i = 0; i < pecIndex; i++) { crc = this.calculateCRC(buffer.readUInt8(i) ^ crc); @@ -132,34 +125,4 @@ export class OmronD6tService return crc[0]; } - - findRelevantPixels(data: number[][]): Pixel[] { - const mean = math.mean(data); - const threshold = mean + this.config.deltaThreshold; - - const relevantPixels: Pixel[] = []; - for (const [x, row] of data.entries()) { - for (const [y, value] of row.entries()) { - if (value >= threshold) { - relevantPixels.push(new Pixel(x, y, value)); - } - } - } - - return relevantPixels; - } - - clusterPixels(pixels: Pixel[]) { - const clusters: Cluster[] = []; - pixels.forEach(pixel => { - const neighbor = clusters.find(cluster => cluster.isNeighboredTo(pixel)); - if (neighbor === undefined) { - clusters.push(new Cluster([pixel])); - } else { - neighbor.pixels.push(pixel); - } - }); - - return clusters; - } } diff --git a/src/integrations/omron-d6t/cluster.entity.ts b/src/util/thermopile/cluster.ts similarity index 92% rename from src/integrations/omron-d6t/cluster.entity.ts rename to src/util/thermopile/cluster.ts index fbb8bd0..7503005 100644 --- a/src/integrations/omron-d6t/cluster.entity.ts +++ b/src/util/thermopile/cluster.ts @@ -1,4 +1,4 @@ -import { Pixel } from './pixel.entity'; +import { Pixel } from './pixel'; export class Cluster { pixels: Pixel[]; diff --git a/src/integrations/omron-d6t/pixel.entity.ts b/src/util/thermopile/pixel.ts similarity index 100% rename from src/integrations/omron-d6t/pixel.entity.ts rename to src/util/thermopile/pixel.ts diff --git a/src/util/thermopile/thermopile-occupancy.sensor.ts b/src/util/thermopile/thermopile-occupancy.sensor.ts new file mode 100644 index 0000000..32e6182 --- /dev/null +++ b/src/util/thermopile/thermopile-occupancy.sensor.ts @@ -0,0 +1,48 @@ +import { Pixel } from './pixel'; +import * as math from 'mathjs'; +import { Cluster } from './cluster'; + +export abstract class ThermopileOccupancySensor { + abstract async getPixelTemperatures(): Promise; + + async getCoordinates(deltaThreshold: number): Promise { + const temperatures = await this.getPixelTemperatures(); + const relevantPixels = this.findRelevantPixels( + temperatures as number[][], + deltaThreshold + ); + const clusters = this.clusterPixels(relevantPixels); + + return clusters.map(cluster => [cluster.center.x, cluster.center.y]); + } + + findRelevantPixels(data: number[][], deltaThreshold: number): Pixel[] { + const mean = math.mean(data); + const threshold = mean + deltaThreshold; + + const relevantPixels: Pixel[] = []; + for (const [x, row] of data.entries()) { + for (const [y, value] of row.entries()) { + if (value >= threshold) { + relevantPixels.push(new Pixel(x, y, value)); + } + } + } + + return relevantPixels; + } + + clusterPixels(pixels: Pixel[]): Cluster[] { + const clusters: Cluster[] = []; + pixels.forEach(pixel => { + const neighbor = clusters.find(cluster => cluster.isNeighboredTo(pixel)); + if (neighbor === undefined) { + clusters.push(new Cluster([pixel])); + } else { + neighbor.pixels.push(pixel); + } + }); + + return clusters; + } +}