Skip to content

Commit

Permalink
feat: add generic MQTT integration
Browse files Browse the repository at this point in the history
Some may wish to drive their own automations through tools like NodeRED,
where it is useful to get more detailed MQTT messages containing the
whole entity state.

Closes #434
  • Loading branch information
mKeRix committed Feb 28, 2021
1 parent 52f0197 commit 848a25b
Show file tree
Hide file tree
Showing 10 changed files with 519 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/.vuepress/config.js
Expand Up @@ -81,6 +81,7 @@ module.exports = {
children: [
'/integrations/',
'/integrations/home-assistant',
'/integrations/mqtt',
'/integrations/bluetooth-low-energy',
'/integrations/bluetooth-classic',
'/integrations/xiaomi-mi',
Expand Down
1 change: 1 addition & 0 deletions docs/integrations/README.md
Expand Up @@ -13,6 +13,7 @@ Looking for a way to integrate OpenHAB? You can use the [Home Assistant Core](ho
| Integration | Supported entities |
| ------------------------------------------ | ------------------ |
| [Home Assistant Core](./home-assistant.md) | All |
| [MQTT](./mqtt.md) | All |

## Presence Detection

Expand Down
132 changes: 132 additions & 0 deletions docs/integrations/mqtt.md
@@ -0,0 +1,132 @@
# MQTT

**Integration Key:** `mqtt`

::: tip

If you are looking to integrate with [Home Assistant Core](https://www.home-assistant.io) via MQTT take a look at the [Home Assistant integration](./home-assistant.md) instead.

:::

The MQTT integration will send messages with room-assistant entity update information to an [MQTT broker](https://mqtt.org).

## Message Format

Entity updates are sent into unique topics for each entity, grouped by instance name and entity type. The topic format is `baseTopic/instanceName/entityType/entityId`.

Each message will have the following properties:

- **entity** - This includes the whole current entity state, like you would see it in the [API](../guide/api.md).
- **hasAuthority** - This boolean value shows whether this update message comes from an entity that has authority over the entity. It will be `false` for distributed entity updates that are emitted from non-leader instances. You may use this to respect the room-assistant leader in your own automations, but you can of course also just pick a single instance to work off.

Optionally the message will also include the following properties:

- **diff** - This is an array of changes to the previous entity state. Each array item includes a `path` to the changed property based off the entity JSON root, the `oldValue` and the `newValue`. Could be used to monitor for specific changes only. This property will not be included when instances emit an entity refresh (e.g. after re-connecting to the MQTT broker).

::: details Example Message

This message could have been posted to `room-assistant/entity/living-room/bluetooth-low-energy-presence-sensor/ble-some-id`:

```json
{
"entity": {
"attributes": {
"distance": 3.6,
"lastUpdatedAt": "2021-02-28T14:17:33.141Z"
},
"id": "ble-some-id",
"name": "Something Room Presence",
"distributed": true,
"stateLocked": true,
"distances": {
"bedroom": {
"lastUpdatedAt": "2021-02-28T14:17:32.605Z",
"distance": 9.4,
"outOfRange": false
},
"living-room": {
"lastUpdatedAt": "2021-02-28T14:17:33.141Z",
"distance": 3.6,
"outOfRange": false
}
},
"timeout": 180,
"measuredValues": {
"bedroom": {
"rssi": -79.81192947697268,
"measuredPower": -59
},
"living-room": {
"rssi": -70.40174705168248,
"measuredPower": -59
}
},
"state": "living-room"
},
"diff": [
{
"path": "/measuredValues/living-room",
"oldValue": {
"rssi": -73.37709762753302,
"measuredPower": -59
},
"newValue": {
"rssi": -70.40174705168248,
"measuredPower": -59
}
},
{
"path": "/distances/living-room",
"oldValue": {
"lastUpdatedAt": "2021-02-28T14:17:32.308Z",
"distance": 3.8,
"outOfRange": false
},
"newValue": {
"lastUpdatedAt": "2021-02-28T14:17:33.141Z",
"distance": 3.6,
"outOfRange": false
}
}
],
"hasAuthority": true
}
```

:::

To get started with your automations based on these topics it is recommended to just explore the data provided in the topics using e.g. a GUI MQTT tool.

## Settings

| Name | Type | Default | Description |
| ------------- | ----------------------------- | ----------------------- | ------------------------------------------------------------ |
| `mqttUrl` | String | `mqtt://localhost:1883` | Connection string for your MQTT broker. |
| `mqttOptions` | [MQTT Options](#mqtt-options) | | Additional options for the MQTT connection. |
| `baseTopic` | String | `room-assistant/entity` | Base for the entity update topics. |
| `qos` | Number | `0` | Quality of Service level that the messages will be sent with. |
| `retain` | Boolean | `false` | Whether to mark the messages as to retain or not. |

### MQTT Options

| Name | Type | Default | Description |
| -------------------- | ------- | ------- | ------------------------------------------------------------ |
| `username` | String | | Username for authentication |
| `password` | String | | Password for authentication |
| `rejectUnauthorized` | Boolean | `true` | Whether MQTTS connections should fail for invalid certificates or not. Set this to `false` if you are using a self-signed certificate and connect via TLS. |

::: details Example Config

```yaml
global:
integrations:
- mqtt
mqtt:
mqttUrl: mqtt://localhost:1883
mqttOptions:
username: youruser
password: yourpass
retain: false
```

:::
2 changes: 2 additions & 0 deletions src/config/definitions/default.ts
Expand Up @@ -10,6 +10,7 @@ import { ShellConfig } from '../../integrations/shell/shell.config';
import { XiaomiMiConfig } from '../../integrations/xiaomi-mi/xiaomi-mi.config';
import { EntitiesConfig } from '../../entities/entities.config';
import { LoggerConfig } from '../logger.config';
import { MqttConfig } from "../../integrations/mqtt/mqtt.config";

export class AppConfig {
global: GlobalConfig = new GlobalConfig();
Expand All @@ -24,6 +25,7 @@ export class AppConfig {
shell: ShellConfig = new ShellConfig();
xiaomiMi: XiaomiMiConfig = new XiaomiMiConfig();
homeAssistant: HomeAssistantConfig = new HomeAssistantConfig();
mqtt: MqttConfig = new MqttConfig();
}

module.exports = new AppConfig();
9 changes: 9 additions & 0 deletions src/integrations/mqtt/mqtt.config.ts
@@ -0,0 +1,9 @@
import { IClientOptions } from "async-mqtt";

export class MqttConfig {
mqttUrl = 'mqtt://localhost:1883';
mqttOptions: IClientOptions = {};
baseTopic = 'room-assistant/entity';
qos: 0 | 1 | 2 = 0;
retain = false;
}
30 changes: 30 additions & 0 deletions src/integrations/mqtt/mqtt.health.spec.ts
@@ -0,0 +1,30 @@
import { mocked } from "ts-jest/utils";
import { MqttService } from "./mqtt.service";
import { MqttHealthIndicator } from "./mqtt.health";
import { HealthCheckError } from "@nestjs/terminus";

jest.mock('./mqtt.service')

describe('MqttHealthIndicator', () => {
const serviceMock = mocked(new MqttService(undefined, undefined, undefined));
const healthIndicator = new MqttHealthIndicator(serviceMock);

it('should report healthy if connection is established', () => {
serviceMock.isConnected.mockReturnValue(true);

const result = healthIndicator.connectionCheck();
expect(result['mqtt_connected'].status).toEqual('up');
});

it('should report unhealthy if connection not established yet', () => {
serviceMock.isConnected.mockReturnValue(undefined);

expect(() => healthIndicator.connectionCheck()).toThrow(HealthCheckError);
});

it('should report unhealthy if connection lost', () => {
serviceMock.isConnected.mockReturnValue(false);

expect(() => healthIndicator.connectionCheck()).toThrow(HealthCheckError);
});
})
25 changes: 25 additions & 0 deletions src/integrations/mqtt/mqtt.health.ts
@@ -0,0 +1,25 @@
import { HealthCheckError, HealthIndicator, HealthIndicatorResult } from "@nestjs/terminus";
import { Injectable, Optional } from "@nestjs/common";
import { MqttService } from "./mqtt.service";
import { HealthIndicatorService } from "../../status/health-indicator.service";

@Injectable()
export class MqttHealthIndicator extends HealthIndicator {
constructor(private readonly mqttService: MqttService, @Optional() healthIndicatorService?: HealthIndicatorService) {
super();
healthIndicatorService?.registerHealthIndicator(async () =>
this.connectionCheck()
);
}

connectionCheck(): HealthIndicatorResult {
const isHealthy = this.mqttService.isConnected();
const result = this.getStatus('mqtt_connected', isHealthy);

if (isHealthy) {
return result;
}

throw new HealthCheckError('No connection to MQTT broker', result);
}
}
16 changes: 16 additions & 0 deletions src/integrations/mqtt/mqtt.module.ts
@@ -0,0 +1,16 @@
import { DynamicModule, Module } from "@nestjs/common";
import { ConfigModule } from "../../config/config.module";
import { EntitiesModule } from "../../entities/entities.module";
import { StatusModule } from "../../status/status.module";
import { MqttService } from "./mqtt.service";

@Module({})
export default class MqttModule {
static forRoot(): DynamicModule {
return {
module: MqttModule,
imports: [ConfigModule, EntitiesModule, StatusModule],
providers: [MqttService]
}
}
}

0 comments on commit 848a25b

Please sign in to comment.