mirror of
https://github.com/outbackdingo/onvif2mqtt.git
synced 2026-01-27 10:19:48 +00:00
Add schema validation / config
This commit is contained in:
13
LICENSE.md
Normal file
13
LICENSE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
Copyright 2020 Dmitri Farkov
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
162
README.md
Normal file
162
README.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# ONVIF2MQTT
|
||||
|
||||
## Purpose
|
||||
|
||||
This package aims to implement a transformation layer between the ONVIF event stream (sourced from IP cameras / camera doorbells) and MQTT (a messaging protocol largely used in home automation).
|
||||
|
||||
Any number of ONVIF devices is supported.
|
||||
|
||||
## Background
|
||||
|
||||
After acquiring an EzViz DB1 camera doorbell, I was happy to find a PIR sensor on it. I was then dismayed to find out that there is no open API to consume the triggered status of it. This project was written to itch that scratch, but it should work for any other ONVIF compliant devices with built-in sensors.
|
||||
|
||||
## Requirements
|
||||
- Docker (unless running Baremetal)
|
||||
- MQTT Broker
|
||||
- At least one ONVIF compatible device implementing events.
|
||||
|
||||
## Hardware Compatibility.
|
||||
- EZViz DB1 Doorbell (flashed with LaView firmware) - **TESTED**
|
||||
- Nelly's Security Doorbell (NSC-DB2)
|
||||
- Laview Halo One Doorbell
|
||||
- RCA HSDB2A Doorbell (flashed with LaView firmware)
|
||||
- Any other ONVIF compliant IP Camera - if it works for you please let me know so that this list can be updated.
|
||||
|
||||
## Supported Events
|
||||
- Motion Sensor
|
||||
- That's it for now, but it's easy to implement new events, just submit a PR or a ticket.
|
||||
|
||||
## Installation
|
||||
|
||||
### Docker
|
||||
This is the recommended method of consumption for everyday users.
|
||||
1. [Pull the image from docker registry.](https://hub.docker.com/r/dfarkov/onvif2mqtt)
|
||||
```
|
||||
docker pull dfarkov/onvif2mqtt:latest
|
||||
```
|
||||
2. Run the image, mounting a config volume containing your configuration (`config.yml`)
|
||||
```
|
||||
docker run -v PATH_TO_CONFIGURATION_FOLDER:/config dfarkov/onvif2mqtt
|
||||
```
|
||||
|
||||
### Baremetal
|
||||
This method requires an installation of NodeJS / NPM. This is the recommended installation method for development purposes.
|
||||
|
||||
1. Clone this repo
|
||||
```
|
||||
git clone https://github.com/dmitrif/onvif2mqtt
|
||||
```
|
||||
2. Navigate to the repo folder.
|
||||
```
|
||||
cd ./onvif2mqtt
|
||||
```
|
||||
3. Install dependencies
|
||||
```
|
||||
npm install
|
||||
```
|
||||
4. Create and fill out a configuration file:
|
||||
```
|
||||
touch config.dev.yml
|
||||
```
|
||||
5. Run the app:
|
||||
```
|
||||
# For development
|
||||
npm run dev
|
||||
|
||||
# For production build
|
||||
npm run build
|
||||
CONFIG_FILE=./config.dev.yml npm run start
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Notes
|
||||
|
||||
Configuration can be placed into a `config.yml` file, containing valid YAML. This file should be placed into the host-mounted config volume; if another location is preferred then the file path can be provided as an environment variable `CONFIG_PATH`.
|
||||
|
||||
### MQTT Notes
|
||||
|
||||
By default this package publishes events to an topic `onvif2mqtt/$ONVIF_DEVICE/$EVENT_TYPE/` with a value of `on | off` for each captured event type.
|
||||
|
||||
### Templating / Custom Topics
|
||||
|
||||
However, by using the `api.templates` option in configuration, one can define a custom `subtopic` and specify a custom template. The following tokens will be interpolated in both the `subtopic` and the `template` values:
|
||||
|
||||
- `${onvifDeviceId}` - name of the ONVIF device (e.g. `doorbell`)
|
||||
- `${eventType}` - type of event captured (e.g. `motion`)
|
||||
- `${eventState}` - boolean state of the event (if applicable)
|
||||
|
||||
The messages will be sent to a topic of the following format: `onvif2mqtt/$ONVIF_DEVICE/$SUBTOPIC`.
|
||||
|
||||
### Schema
|
||||
|
||||
```yaml
|
||||
api:
|
||||
templates:
|
||||
#Subtopics can be nested with `/` and are interpolated
|
||||
- subtopic: ${eventType}/json
|
||||
# Should this message be retained by MQTT
|
||||
# Defaults to true
|
||||
retain: false
|
||||
# Template that should be published to the topic,
|
||||
# values are interpolated
|
||||
template: >-
|
||||
{
|
||||
"device": "${onvifDeviceId}",
|
||||
"eventType": "${eventType}",
|
||||
"state": "${eventState}"
|
||||
}
|
||||
# You can specify any number of custom subtopics.
|
||||
- subtopic: hello_world
|
||||
template: hello from ${onvifDeviceId}
|
||||
# MQTT Broker configuration,
|
||||
# required due to nature of project.
|
||||
mqtt:
|
||||
host: 192.168.0.57
|
||||
port: 1883
|
||||
username: user
|
||||
password: password
|
||||
# All of your ONVIF devices
|
||||
onvif:
|
||||
# Name for the device (used in MQTT topic)
|
||||
- name: doorbell
|
||||
hostname: localhost
|
||||
port: 80
|
||||
username: admin
|
||||
password: admin
|
||||
```
|
||||
|
||||
### Examples / Guides
|
||||
|
||||
### Using with Shinobi
|
||||
|
||||
1. [Configure Shinobi.video to use `mqtt`](https://hub.shinobi.video/articles/view/xEMps3O4y4VEaYk)
|
||||
2. Configure a shinobi monitor to trigger motion detector on API events.
|
||||
3. Add custom subtopic for shinobi:
|
||||
```yaml
|
||||
...
|
||||
api:
|
||||
templates:
|
||||
- subtopic: shinobi
|
||||
retain: false
|
||||
template: >-
|
||||
{
|
||||
"plug": "${onvifDeviceId}",
|
||||
"reason": "${eventType}",
|
||||
"name": "${onvifDeviceId}"
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
### Using with HomeAssistant
|
||||
1. Install the MQTT HomeAssistant integration.
|
||||
2. Define custom `binary_sensor` in HomeAssistant's `configuration.yaml`:
|
||||
```yaml
|
||||
binary_sensor doorbell_motion:
|
||||
- platform: mqtt
|
||||
name: doorbell_motion
|
||||
state_topic: "onvif2mqtt/doorbell/motion"
|
||||
```
|
||||
|
||||
### Getting Started
|
||||
Simplest way forward is to base your configuration off [`config.sample.yml`](https://github.com/dmitrif/onvif2mqtt/blob/master/config.sample.yml).
|
||||
9
config.sample.yml
Normal file
9
config.sample.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
mqtt:
|
||||
host: 127.0.0.1
|
||||
port: 1883
|
||||
onvif:
|
||||
- name: device_name
|
||||
hostname: 127.0.0.1
|
||||
port: 80
|
||||
username: username
|
||||
password: password
|
||||
@@ -1,3 +1,5 @@
|
||||
log: debug
|
||||
api:
|
||||
templates:
|
||||
mqtt:
|
||||
onvif:
|
||||
6171
licenses.txt
Normal file
6171
licenses.txt
Normal file
File diff suppressed because it is too large
Load Diff
3138
package-lock.json
generated
3138
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "CONFIG_PATH=./config.dev.yml nodemon --exec babel-node src/index.js | pino-pretty",
|
||||
"dev": "CONFIG_PATH=./config.dev.yml nodemon -e js,yml --exec babel-node src/index.js | pino-pretty",
|
||||
"build": "babel src --out-dir dist/",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint ./src/**/*.js"
|
||||
@@ -18,11 +18,14 @@
|
||||
"eslint": "6.8.0",
|
||||
"eslint-config-airbnb": "18.1.0",
|
||||
"eslint-plugin-import": "2.20.1",
|
||||
"i": "0.3.6",
|
||||
"lodash.at": "4.6.0",
|
||||
"nodemon": "2.0.2",
|
||||
"npm": "6.14.4",
|
||||
"onvif": "0.6.2",
|
||||
"pino": "5.17.0",
|
||||
"pino-pretty": "3.6.1",
|
||||
"validate": "5.1.0",
|
||||
"yaml": "1.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import at from 'lodash.at';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import fs, { exists } from 'fs';
|
||||
import yaml from 'yaml';
|
||||
import process from 'process';
|
||||
import validator from './ConfigValidator';
|
||||
import logger, { setLoggingLevel } from './Logger';
|
||||
|
||||
const DEFAULT_CONFIG_PATH = path.resolve(__dirname, '../default-config.yml');
|
||||
const CONFIG_PATH = process.env.CONFIG_PATH || '/config/config.yml';
|
||||
|
||||
class Config {
|
||||
@@ -24,21 +26,55 @@ class Config {
|
||||
return Config.instance;
|
||||
}
|
||||
|
||||
_loadConfig = () => {
|
||||
logger.info('Loading configuration.', { configPath: CONFIG_PATH });
|
||||
_loadDefaultConfig = () => {
|
||||
const defaultConfigFileRef = fs.readFileSync(DEFAULT_CONFIG_PATH, 'utf8');
|
||||
return yaml.parse(defaultConfigFileRef);
|
||||
};
|
||||
|
||||
const defaultConfigPath = path.resolve(__dirname, '../default-config.yml');
|
||||
const defaultConfigFileRef = fs.readFileSync(defaultConfigPath, 'utf8');
|
||||
_loadUserConfig = () => {
|
||||
logger.info('Loading configuration.', { configPath: CONFIG_PATH });
|
||||
|
||||
const configFileRef = fs.readFileSync(CONFIG_PATH, 'utf8');
|
||||
|
||||
const parsedDefaultConfig = yaml.parse(defaultConfigFileRef);
|
||||
const parsedConfig = yaml.parse(configFileRef);
|
||||
let parsedConfig = {};
|
||||
|
||||
return {
|
||||
...parsedDefaultConfig,
|
||||
...parsedConfig
|
||||
try {
|
||||
parsedConfig = yaml.parse(configFileRef);
|
||||
} catch(e) {
|
||||
logger.error('Invalid YAML found in configuration', { source: e.source });
|
||||
logger.error(e);
|
||||
logger.error();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return parsedConfig;
|
||||
};
|
||||
|
||||
_validate = (config) => {
|
||||
logger.info('Validating configuration file.');
|
||||
const validationErrors = validator.validate(config);
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
logger.error('Config validation failed...');
|
||||
validationErrors.forEach(({ path, message }) => {
|
||||
logger.error(message, { path });
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
_loadConfig = () => {
|
||||
const defaultConfig = this._loadDefaultConfig();
|
||||
const userConfig = this._loadUserConfig();
|
||||
|
||||
const mergedConfig = {
|
||||
...defaultConfig,
|
||||
...userConfig
|
||||
};
|
||||
|
||||
this._validate(mergedConfig);
|
||||
|
||||
return mergedConfig;
|
||||
};
|
||||
|
||||
get = path => at(this._config, path)[0];
|
||||
|
||||
88
src/ConfigValidator.js
Normal file
88
src/ConfigValidator.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import Schema from 'validate';
|
||||
import pino from 'pino';
|
||||
|
||||
const pinoLogLevels = Object.keys(pino.levels.values);
|
||||
|
||||
console.log(pino.levels);
|
||||
|
||||
/*
|
||||
log: debug
|
||||
api:
|
||||
templates:
|
||||
- subtopic: shinobi
|
||||
retain: false
|
||||
template: >-
|
||||
{
|
||||
"plug": "${onvifDeviceId}",
|
||||
"reason": "${eventType}",
|
||||
"name": "${onvifDeviceId}"
|
||||
}
|
||||
mqtt:
|
||||
host: localhost
|
||||
port: 1883
|
||||
onvif:
|
||||
- name: doorbell
|
||||
hostname: localhost
|
||||
port: 80
|
||||
username: admin
|
||||
password: SHAGCB
|
||||
*/
|
||||
|
||||
const configSchema = new Schema({
|
||||
log: {
|
||||
type: String,
|
||||
use: {
|
||||
mustMatchLogLevels: val => pinoLogLevels.includes(val),
|
||||
},
|
||||
},
|
||||
api: {
|
||||
templates: [{
|
||||
subtopic: {
|
||||
required: true,
|
||||
},
|
||||
retain: {
|
||||
type: Boolean
|
||||
},
|
||||
template: {
|
||||
type: String,
|
||||
}
|
||||
}]
|
||||
},
|
||||
mqtt: {
|
||||
required: true,
|
||||
host: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
onvif: {
|
||||
type: Array,
|
||||
required: true,
|
||||
each: {
|
||||
name: {
|
||||
required: true,
|
||||
},
|
||||
hostname: {
|
||||
required: true,
|
||||
},
|
||||
port: {
|
||||
required: true,
|
||||
},
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
configSchema.message({
|
||||
mustMatchLogLevels: path => `${path} must be one of [${pinoLogLevels.join(',')}]`
|
||||
});
|
||||
|
||||
class Validator {
|
||||
validate = (config) => {
|
||||
return configSchema.validate(
|
||||
config
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default new Validator();
|
||||
@@ -40,14 +40,23 @@ export default class Manager {
|
||||
publishTemplates = (onvifDeviceId, eventType, eventState) => {
|
||||
const templates = config.get('api.templates');
|
||||
|
||||
if (!templates) {
|
||||
return;
|
||||
}
|
||||
|
||||
templates.forEach(({
|
||||
subtopic, template, retain
|
||||
}) => {
|
||||
this.publisher.publish(onvifDeviceId, subtopic, interpolateTemplateValues(template, {
|
||||
const interpolationValues = {
|
||||
onvifDeviceId,
|
||||
eventType,
|
||||
eventState
|
||||
}), retain);
|
||||
};
|
||||
|
||||
const interpolatedSubtopic = interpolateTemplateValues(subtopic, interpolationValues);
|
||||
const interpolatedTemplate = interpolateTemplateValues(template, interpolationValues);
|
||||
|
||||
this.publisher.publish(onvifDeviceId, interpolatedSubtopic, interpolatedTemplate, retain);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user