Skip to content

Commit

Permalink
Support for sending images to multiple locations.
Browse files Browse the repository at this point in the history
  • Loading branch information
Sunoo committed Jul 29, 2020
1 parent f19aa8a commit e0fa42c
Show file tree
Hide file tree
Showing 5 changed files with 108 additions and 107 deletions.
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Edit your `config.json` accordingly. Configuration sample:

- `ftp_port`: The port to run the FTP server on. (Default: `5000`)
- `http_port`: The HTTP port used by homebridge-camera-ffmpeg. (Default: `8080`)
- `bot_token`: The token given by @BotFather when creating the Telegram bot.
- `cameras`: _(Required)_ Array of Dafang Hacks camera configs (multiple supported).
- `name`: _(Required)_ Name of your camera. (Needs to be the same as in homebridge-camera-ffmpeg config)
- `cooldown`: Cooldown in seconds. Set to 0 to disable.
Expand All @@ -45,7 +46,10 @@ Edit your `config.json` accordingly. Configuration sample:
- `username`: Username of the remote FTP server. If not set, no authentication will be used.
- `password`: Password of the remote FTP server. If not set, no authentication will be used.
- `tls`: Should TLS be used to connect to remote FTP server? (Default: `false`)
- `path`: The location to store incoming images. If FTP Server is not set, it will be treated as a local path. If neither this nor FTP Server are set, images will not be stored.
- `path`: The location on the remote FTP server to store incoming images.
- `local_path`: The location on the system Homebridge is running on to store incoming images.
- `chat_id`: The chat ID given by the bot after sending '/start' to it.
- `caption`: If true, sends the filename as the caption to the image.

### Camera Configuration

Expand All @@ -55,3 +59,15 @@ To use this plugin, you'll need to configure the FTP settings on your camera as
- `Port`: The value you used for `ftp_port` in the plugin configuration.
- `Username` and `Password`: Any value can currently be used, as authentication is not currently supported in this plugin. That will likely be added in future versions.
- `Path`: This should be the name of your camera, exactly as defined in the homebridge-camera-ffmpeg plugin.

### Telegram Bot Setup

If you want to use the Telegram functionality of this plugin, you'll need to set up your own Telegram bot. Here are instructions on doing that with this plugin.

- Send `/newbot` to [@BotFather](https://t.me/botfather) on Telegram and answer the questions it asks you.
- When it sends `Use this token to access the HTTP API`, copy the token and add it to this plugin's config under `bot_token`.
- Restart Homebridge, this will allow the bot to start and connect to Telegram.
- If you want the bot to message a group, invite the bot to that group.
- From the chat you want to receive notifications, send `/start`.
- Your bot will send you `Chat ID for (chatname)`, copy that ID to this plugin's config under `chat_id`. This is set uniquely for each camera.
- Restart Homebridge again. Your bot is now configured.
13 changes: 2 additions & 11 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,6 @@
"minimum": 0,
"description": "Set to 0 to disable."
},
"method": {
"title": "Storage Method",
"type": "string",
"oneOf": [
{ "title": "Remote FTP", "enum": ["ftp"] },
{ "title": "Local Folder", "enum": ["local"] },
{ "title": "Telegram Message", "enum": ["telegram"] }
],
"description": "Where to store the image uploaded to the plugin via FTP."
},
"server": {
"title": "Remote FTP Server",
"type": "string",
Expand Down Expand Up @@ -117,6 +107,7 @@
"layout": [
{
"type": "section",
"title": "Global Settings",
"expandable": true,
"expanded": true,
"items": [
Expand All @@ -143,7 +134,7 @@
{
"key": "cameras[]",
"type": "section",
"title": "FTP Settings",
"title": "Remote FTP Settings",
"expandable": true,
"expanded": false,
"items": [
Expand Down
7 changes: 0 additions & 7 deletions src/configTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export type FtpMotionPlatformConfig = {
export type CameraConfig = {
name: string;
cooldown: number;
method: StorageMethod;
server: string;
port: number;
username: string;
Expand All @@ -20,9 +19,3 @@ export type CameraConfig = {
chat_id: number;
caption: boolean;
};

export enum StorageMethod {
FTP = 'ftp',
Local = 'local',
Telegram = 'telegram'
}
19 changes: 10 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import {
PlatformAccessory,
PlatformConfig
} from 'homebridge';
import Bunyan from 'bunyan';
import { FtpSrv } from 'ftp-srv';
import ip from 'ip';
import Bunyan from 'bunyan';
import Stream from 'stream';
import { Telegraf, Telegram, Context } from 'telegraf';
import { CameraConfig, FtpMotionPlatformConfig } from './configTypes';
import { MotionFS } from './motionfs';
import { Telegraf, Telegram, Context } from 'telegraf';

const PLUGIN_NAME = 'homebridge-ftp-motion';
const PLATFORM_NAME = 'ftpMotion';
Expand All @@ -35,24 +35,25 @@ class FtpMotionPlatform implements DynamicPlatformPlugin {
if (ascii) {
this.cameraConfigs.push(camera);
} else {
this.log.warn('Camera "' + camera.name + '" contains non-ASCII characters. FTP does not support Unicode, ' +
this.log.warn('[' + camera.name + '] Camera name contains non-ASCII characters. FTP does not support Unicode, ' +
'so it is being skipped. Please rename this camera if you wish to use this plugin with it.');
}
});

if (this.config.bot_token) {
const bot = new Telegraf(this.config.bot_token);
bot.catch((err: Error, ctx: Context) => {
this.log.error('Telegram error: Update Type: ' + ctx.updateType + ', Message: ' + err.message);
this.log.error('[Telegram] Error: Update Type: ' + ctx.updateType + ', Message: ' + err.message);
});
bot.start((ctx) => {
if (ctx.message) {
ctx.reply('Chat ID: ' + ctx.message.chat.id);
const from = ctx.message.chat.title || ctx.message.chat.username || 'unknown';
this.log.debug('Telegram Chat ID for ' + from + ': ' + ctx.message.chat.id);
const message = 'Chat ID for ' + from + ': ' + ctx.message.chat.id;
ctx.reply(message);
this.log.debug('[Telegram] ' + message);
}
});
this.log('Connecting to Telegram.');
this.log('[Telegram] Connecting to Telegram...');
bot.launch();
this.telegram = bot.telegram;
}
Expand All @@ -71,7 +72,7 @@ class FtpMotionPlatform implements DynamicPlatformPlugin {
const logStream = new Stream.Writable({
write: (chunk: string, encoding: BufferEncoding, callback): void => {
const data = JSON.parse(chunk);
const message = 'FTP Server: ' + data.msg;
const message = '[FTP Server] [Bunyan] ' + data.msg;
if (data.level >= 50) {
this.log.error(message);
} else if (data.level >= 40) {
Expand Down Expand Up @@ -102,7 +103,7 @@ class FtpMotionPlatform implements DynamicPlatformPlugin {
});
ftpServer.listen()
.then(() => {
this.log('FTP server started on port ' + ftpPort + '.');
this.log('[FTP Server] Started on port ' + ftpPort + '.');
});
}
}
Expand Down
158 changes: 79 additions & 79 deletions src/motionfs.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Logging } from 'homebridge';
import { Client as FtpClient } from 'basic-ftp';
import fs from 'fs';
import { FileSystem, FtpConnection } from 'ftp-srv';
import { Stream, TransformCallback } from 'stream';
import http from 'http';
import pathjs from 'path';
import fs from 'fs';
import { Client as FtpClient } from 'basic-ftp';
import { Readable, Stream, Transform, TransformCallback, Writable } from 'stream';
import { Telegram } from 'telegraf';
import { CameraConfig, StorageMethod } from './configTypes';
import { Transform, Writable} from 'stream';
import { CameraConfig } from './configTypes';

export class MotionFS extends FileSystem {
private readonly log: Logging;
Expand Down Expand Up @@ -83,41 +82,46 @@ export class MotionFS extends FileSystem {
if (pathSplit.length == 1) {
const camera = this.cameraConfigs.find((camera: CameraConfig) => camera.name == pathSplit[0]);
if (camera) {
this.log.debug(camera.name + ' motion detected.');
this.log.debug('[' + camera.name + '] [' + fileName + '] Receiving file.');
if (!this.timers.get(camera.name)) {
try {
http.get('http://127.0.0.1:' + this.httpPort + '/motion?' + camera.name);
} catch (ex) {
this.log.error(camera.name + ': Error making HTTP call: ' + ex);
this.log.error('[' + camera.name + '] [' + fileName + '] Error making HTTP call: ' + ex);
}
} else {
this.log.debug('Motion set received, but cooldown running: ' + camera.name);
this.log.debug('[' + camera.name + '] [' + fileName + '] Motion set received, but cooldown running.');
}
if (camera.cooldown > 0) {
if (this.timers.get(camera.name)) {
this.log.debug('Cancelling existing cooldown timer: ' + camera.name);
this.log.debug('[' + camera.name + '] [' + fileName + '] Cancelling existing cooldown timer.');
const timer = this.timers.get(camera.name);
if (timer) {
clearTimeout(timer);
}
}
this.log.debug('Cooldown enabled, starting timer: ' + camera.name);
this.log.debug('[' + camera.name + '] [' + fileName + '] Cooldown enabled, starting timer.');
const timeout = setTimeout(((): void => {
this.log.debug('Cooldown finished: ' + camera.name);
this.log.debug('[' + camera.name + '] Cooldown finished.');
try {
http.get('http://127.0.0.1:' + this.httpPort + '/motion/reset?' + camera.name);
} catch (ex) {
this.log.error(camera.name + ': Error making HTTP call: ' + ex);
this.log.error('[' + camera.name + '] [' + fileName + '] Error making HTTP call: ' + ex);
}
this.timers.delete(camera.name);
}).bind(this), camera.cooldown * 1000);
this.timers.set(camera.name, timeout);
}
return this.storeImage(fileName, camera);
}
} else {
this.connection.reply(550, 'Permission denied.');
return new Stream.Writable({
write: (chunk: any, encoding: BufferEncoding, callback): void => { // eslint-disable-line @typescript-eslint/no-explicit-any
callback();
}
});
}
this.connection.reply(550, 'Permission denied.');
return this.getNullStream();
}

chmod(path: string, mode: string): any { // eslint-disable-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -145,85 +149,81 @@ export class MotionFS extends FileSystem {
return;
}

private uploadFtp(fileName: string, camera: CameraConfig): Writable {
if (camera.server) {
const transformStream = this.getTransformStream();
const client = new FtpClient();
const remotePort = camera.port || 21;
private uploadFtp(stream: Readable, fileName: string, camera: CameraConfig): void {
this.log.debug('[' + camera.name + '] [Remote FTP] [' + fileName + '] Connecting to ' + camera.server + '.');
const client = new FtpClient();
client.access({
host: camera.server,
port: camera.port || 21,
user: camera.username,
password: camera.password,
secure: camera.tls
}).then(() => {
const remotePath = camera.path || '/';
client.access({
host: camera.server,
port: remotePort,
user: camera.username,
password: camera.password,
secure: camera.tls
}).then(() => {
return client.ensureDir(remotePath);
}).then(() => {
const filePath = pathjs.resolve(camera.path, fileName);
this.log.debug(camera.name + ': Uploading file to ' + filePath);
return client.uploadFrom(transformStream, fileName);
}).then(() => {
this.log.debug(camera.name + ': Uploaded file: ' + fileName);
}).catch((err: Error) => {
this.log.error(camera.name + ': Error uploading file: ' + err.message);
}).finally(() => {
client.close();
});
return transformStream;
} else {
this.log.warn(camera.name + ': FTP upload selected by no remote FTP server defined.');
return this.getNullStream();
}
this.log.debug('[' + camera.name + '] [Remote FTP] [' + fileName + '] Changing directory to ' + remotePath + '.');
return client.ensureDir(remotePath);
}).then(() => {
this.log.debug('[' + camera.name + '] [Remote FTP] [' + fileName + '] Uploading file.');
return client.uploadFrom(stream, fileName);
}).then(() => {
this.log.debug('[' + camera.name + '] [Remote FTP] [' + fileName + '] Uploaded file.');
}).catch((err: Error) => {
this.log.error('[' + camera.name + '] [Remote FTP] [' + fileName + '] Error uploading file: ' + err.message);
}).finally(() => {
client.close();
});
}

private saveLocal(fileName: string, camera: CameraConfig): Writable {
if (!camera.local_path) {
this.log.warn(camera.name + ': Local storage selected by no local path defined.');
return this.getNullStream();
} else {
const filePath = pathjs.resolve(camera.local_path, fileName);
this.log.debug(camera.name + ': Writing file to ' + filePath);
const fileStream = fs.createWriteStream(filePath);
fileStream.on('finish', () => {
this.log.debug(camera.name + ': Wrote file: ' + fileName);
});
fileStream.on('error', (err: Error) => {
this.log.error(camera.name + ': Error writing file: ' + err.message);
});
return fileStream;
}
private saveLocal(stream: Readable, fileName: string, camera: CameraConfig): void {
const filePath = pathjs.resolve(camera.local_path, fileName);
this.log.debug('[' + camera.name + '] [Local] [' + fileName + '] Writing file to ' + filePath + '.');
const fileStream = fs.createWriteStream(filePath);
fileStream.on('finish', () => {
this.log.debug('[' + camera.name + '] [Local] [' + fileName + '] Wrote file.');
});
fileStream.on('error', (err: Error) => {
this.log.error('[' + camera.name + '] [Local] [' + fileName + '] Error writing file: ' + err.message);
});
stream.pipe(fileStream);
}

private sendTelegram(fileName: string, camera: CameraConfig): Writable {
private sendTelegram(stream: Readable, fileName: string, camera: CameraConfig): void {
if (!this.telegram) {
this.log.warn(camera.name + ': Telegram message selected by no bot token defined.');
return this.getNullStream();
} else if (!camera.chat_id) {
this.log.warn(camera.name + ': Telegram message selected by no chat ID defined.');
return this.getNullStream();
this.log.warn('[' + camera.name + '] [Telegram] [' + fileName + '] Chat ID configured but no bot token defined. Skipping.');
} else {
const transformStream = this.getTransformStream();
this.log.debug(camera.name + ': Sending ' + fileName + ' to chat ' + camera.chat_id + '.');
this.log.debug('[' + camera.name + '] [Telegram] [' + fileName + '] Sending to chat ' + camera.chat_id + '.');
const caption = camera.caption ? { caption: fileName } : {};
this.telegram.sendPhoto(camera.chat_id, { source: transformStream }, caption)
this.telegram.sendPhoto(camera.chat_id, { source: stream }, caption)
.then(() => {
this.log.debug(camera.name + ': Sent file: ' + fileName);
this.log.debug('[' + camera.name + '] [Telegram] [' + fileName + '] Sent file.');
});
return transformStream;
}
}

private storeImage(fileName: string, camera: CameraConfig): Writable {
switch (camera.method) {
case StorageMethod.FTP:
return this.uploadFtp(fileName, camera);
case StorageMethod.Local:
return this.saveLocal(fileName, camera);
case StorageMethod.Telegram:
return this.sendTelegram(fileName, camera);
default:
return this.getNullStream();
const stream = new Stream.Transform({
transform: (chunk: any, encoding: BufferEncoding, callback: TransformCallback): void => { // eslint-disable-line @typescript-eslint/no-explicit-any
callback(null, chunk);
}
});
let upload = false;
if (camera.server) {
upload = true;
this.uploadFtp(stream, fileName, camera);
}
if (camera.local_path) {
upload = true;
this.saveLocal(stream, fileName, camera);
}
if (camera.chat_id) {
upload = true;
this.sendTelegram(stream, fileName, camera);
}
if (upload) {
return stream;
} else {
this.log.debug('[' + camera.name + '] [' + fileName + '] No image store options configured. Discarding.');
return this.getNullStream();
}
}

Expand Down

0 comments on commit e0fa42c

Please sign in to comment.