@apocaliss92/scrypted-reolink-hub
Version:
Reolink (HUBs) Plugin for Scrypted
503 lines (435 loc) • 19.3 kB
text/typescript
import sdk, { DeviceCreatorSettings, DeviceInformation, HttpRequest, HttpRequestHandler, HttpResponse, Reboot, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue, VideoClips } from "@scrypted/sdk";
import { StorageSettings } from '@scrypted/sdk/storage-settings';
import fs from 'fs';
import http from 'http';
import { getBaseLogger, logLevelSetting } from "../../scrypted-apocaliss-base/src/basePlugin";
import { cleanup } from "../../scrypted-reolink-videoclips/src/utils";
import { RtspProvider } from "../../scrypted/plugins/rtsp/src/rtsp";
import { name } from '../package.json';
import { ReolinkCamera } from "./camera";
import { DeviceInputData, ReolinkHubClient } from './reolink-api';
import ReolinkVideoclips from "./videoclips";
export const pluginId = name;
export const REOLINK_HUB_VIDEOCLIPS_INTERFACE = `${pluginId}:videoclips`;
export const videoclipsNativeId = 'reolinkHubVideoclips';
class ReolinkProvider extends RtspProvider implements Settings, HttpRequestHandler, Reboot {
client: ReolinkHubClient;
videoclipsDevice: ReolinkVideoclips;
processing = false;
storageSettings = new StorageSettings(this, {
logLevel: {
...logLevelSetting,
},
address: {
title: 'HUB IP',
type: 'string',
},
username: {
title: 'Username',
placeholder: 'admin',
defaultValue: 'admin',
type: 'string',
},
password: {
title: 'Password',
type: 'password',
},
port: {
title: 'HTTP Port',
subgroup: 'Advanced',
defaultValue: 80,
placeholder: '80',
type: 'number',
},
rtspPort: {
subgroup: 'Advanced',
title: 'RTSP Port',
placeholder: '554',
defaultValue: 554,
type: 'number'
},
downloadFolder: {
title: 'Directory where to cache thumbnails and videoclips',
description: 'Default to the plugin folder',
type: 'string',
group: 'Videoclips'
},
clearDownloadedData: {
title: 'clear stored data',
type: 'button',
group: 'Videoclips',
onPut: async () => cleanup(this.storageSettings.values.downloadFolder)
},
abilities: {
json: true,
hide: true,
defaultValue: {}
},
devicesData: {
json: true,
hide: true,
defaultValue: {}
},
hubData: {
json: true,
hide: true,
defaultValue: {}
},
});
lastHubInfoCheck = undefined;
lastErrorsCheck = Date.now();
lastDevicesStatusCheck = Date.now();
cameraChannelMap = new Map<string, ReolinkCamera>();
constructor() {
super();
const logger = this.getLogger();
this.init().catch(logger.error);
}
public getLogger() {
return getBaseLogger({
console: this.console,
storage: this.storageSettings,
});
}
async reboot() {
const client = this.getClient();
if (!client) {
return;
}
await client.reboot();
}
async init() {
const client = this.getClient();
await client.login();
const logger = this.getLogger();
setInterval(async () => {
if (this.processing) {
return;
}
this.processing = true;
try {
const now = Date.now();
const client = this.getClient();
if (now - this.lastErrorsCheck > 60 * 1000) {
this.lastErrorsCheck = now;
await client.checkErrors();
}
if (!this.lastHubInfoCheck || now - this.lastHubInfoCheck > 1000 * 60 * 5) {
this.lastHubInfoCheck = now;
const { abilities, hubData, } = await client.getHubInfo();
const { devicesData, channelsResponse, response } = await client.getDevicesInfo();
logger.log('Hub info data fetched');
logger.info(`${JSON.stringify({ abilities, hubData, devicesData, channelsResponse, response })}`);
this.storageSettings.values.abilities = abilities;
this.storageSettings.values.hubData = hubData;
this.storageSettings.values.devicesData = devicesData;
}
const devicesMap = new Map<number, DeviceInputData>();
let anyBattery = false;
let anyAwaken = false;
let anyFound = false;
this.cameraChannelMap.forEach((camera) => {
if (camera) {
anyFound = true;
const channel = camera.storageSettings.values.rtspChannel;
const abilities = camera.getAbilities();
if (abilities) {
const hasBattery = camera.hasBattery();
const hasPirEvents = camera.hasPirEvents();
const hasFloodlight = camera.hasFloodlight();
const sleeping = camera.sleeping;
const { hasPtz } = camera.getPtzCapabilities();
devicesMap.set(Number(channel), {
hasFloodlight,
hasBattery,
hasPirEvents,
hasPtz,
sleeping
});
if (hasBattery && !anyBattery) {
anyBattery = true;
}
if (!sleeping && !anyAwaken) {
anyAwaken = true;
}
}
}
});
if (anyFound) {
const eventsRes = await client.getEvents(devicesMap);
logger.debug(`Events call result: ${JSON.stringify(eventsRes)}`);
this.cameraChannelMap.forEach((camera) => {
if (camera) {
const channel = camera.storageSettings.values.rtspChannel;
const cameraEventsData = eventsRes?.parsed[channel];
if (cameraEventsData) {
camera.processEvents(cameraEventsData);
}
}
});
}
if (anyBattery) {
const { batteryInfoData, response } = await client.getBatteryInfo(devicesMap);
logger.debug(`Battery info call result: ${JSON.stringify({ batteryInfoData, response })}`);
this.cameraChannelMap.forEach((camera) => {
if (camera) {
const channel = camera.storageSettings.values.rtspChannel;
const cameraBatteryData = batteryInfoData[channel];
if (cameraBatteryData) {
camera.processBatteryData(cameraBatteryData);
}
}
});
}
if (now - this.lastDevicesStatusCheck > 15 * 1000 && anyAwaken) {
this.lastDevicesStatusCheck = now;
const { deviceStatusData, response } = await client.getStatusInfo(devicesMap);
logger.info(`Status info call result: ${JSON.stringify({ deviceStatusData, response })}`);
this.cameraChannelMap.forEach((camera) => {
if (camera) {
const channel = camera.storageSettings.values.rtspChannel;
const cameraDeviceStatusData = deviceStatusData[channel];
if (cameraDeviceStatusData) {
camera.processDeviceStatusData(cameraDeviceStatusData);
}
}
});
}
} catch (e) {
this.getLogger().error('Error on events flow', e);
} finally {
this.processing = false;
}
}, 1000);
await sdk.deviceManager.onDeviceDiscovered(
{
name: 'Reolink HUB Videoclips',
nativeId: videoclipsNativeId,
interfaces: [ScryptedInterface.MixinProvider, ScryptedInterface.Settings],
type: ScryptedDeviceType.API,
}
);
}
async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
const url = new URL(`http://localhost${request.url}`);
const params = url.searchParams.get('params') ?? '{}';
const logger = this.getLogger();
try {
const [_, __, ___, ____, _____, webhook] = url.pathname.split('/');
const { nativeId, videoclipPath } = JSON.parse(params);
const tmpDev = this.devices.get(nativeId);
const deviceId = tmpDev.id;
const dev = this.videoclipsDevice.currentMixinsMap[deviceId];
const devConsole = dev.getLogger();
const actualDevice = sdk.systemManager.getDeviceById<VideoClips>(deviceId);
try {
if (webhook === 'videoclip') {
if (dev.storageSettings.values.ftp) {
const stat = fs.statSync(videoclipPath);
const fileSize = stat.size;
const range = request.headers.range;
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
const file = fs.createReadStream(videoclipPath, { start, end });
const sendVideo = async () => {
return new Promise<void>((resolve, reject) => {
try {
response.sendStream((async function* () {
for await (const chunk of file) {
yield chunk;
}
})(), {
code: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': 'video/mp4',
}
});
resolve();
} catch (err) {
reject(err);
}
});
};
try {
await sendVideo();
return;
} catch (e) {
devConsole.error('Error fetching videoclip', e);
}
} else {
response.sendFile(videoclipPath, {
code: 200,
headers: {
'Content-Length': fileSize,
'Content-Type': 'video/mp4',
}
});
}
return;
} else {
const api = this.getClient();
const { downloadPathWithHost } = await api.getVideoClipUrl(videoclipPath, deviceId);
const sendVideo = async () => {
return new Promise<void>((resolve, reject) => {
http.get(downloadPathWithHost, { headers: request.headers }, (httpResponse) => {
if (httpResponse.statusCode[0] === 400) {
reject(new Error(`Error loading the video: ${httpResponse.statusCode} - ${httpResponse.statusMessage}. Headers: ${JSON.stringify(request.headers)}`));
return;
}
try {
response.sendStream((async function* () {
for await (const chunk of httpResponse) {
yield chunk;
}
})(), {
headers: httpResponse.headers
});
resolve();
} catch (err) {
reject(err);
}
}).on('error', (e) => {
devConsole.error('Error fetching videoclip', e);
reject(e)
});
});
};
try {
await sendVideo();
return;
} catch (e) {
devConsole.error('Error fetching videoclip', e);
}
}
} else if (webhook === 'thumbnail') {
devConsole.info(`Thumbnail requested: ${JSON.stringify({
videoclipPath,
deviceId,
})}`);
const thumbnailMo = await actualDevice.getVideoClipThumbnail(videoclipPath);
if (thumbnailMo) {
const jpeg = await sdk.mediaManager.convertMediaObjectToBuffer(thumbnailMo, 'image/jpeg');
response.send(jpeg, {
headers: {
'Content-Type': 'image/jpeg',
}
});
return;
} else {
response.send('', {
headers: {
'Content-Type': 'image/jpeg',
}
});
return;
}
}
} catch (e) {
devConsole.error(`Error in webhook`, e);
response.send(`${JSON.stringify(e)}, ${e.message}`, {
code: 400,
});
return;
}
response.send(`Webhook not found: ${url.pathname}`, {
code: 404,
});
return;
} catch (e) {
logger.error('Error in data parsing for webhook', e);
response.send(`Error in data parsing for webhook: ${JSON.stringify({
params,
url: request.url
})}`, {
code: 500,
});
}
}
async getSettings(): Promise<Setting[]> {
const settings = await this.storageSettings.getSettings();
return settings;
}
async putSetting(key: string, value: SettingValue): Promise<void> {
this.client = undefined;
return this.storageSettings.putSetting(key, value);
}
getHttpAddress() {
const { address, port } = this.storageSettings.values;
return `${address}:${port}`;
}
getClient() {
if (!this.client) {
const { password, username } = this.storageSettings.values;
this.client = new ReolinkHubClient(this.getHttpAddress(), username, password, this.getLogger());
}
return this.client;
}
getScryptedDeviceCreator(): string {
return 'Reolink HUB Camera';
}
getAdditionalInterfaces() {
return [
ScryptedInterface.VideoCameraConfiguration,
ScryptedInterface.Camera,
ScryptedInterface.MotionSensor,
ScryptedInterface.VideoTextOverlays,
ScryptedInterface.MixinProvider,
pluginId,
];
}
async getDevice(nativeId: string) {
if (nativeId === videoclipsNativeId)
return this.videoclipsDevice ||= new ReolinkVideoclips(videoclipsNativeId, this);
return super.getDevice(nativeId);
}
async createDevice(settings: DeviceCreatorSettings, nativeId?: string): Promise<string> {
let info: DeviceInformation = {};
const rtspChannel = parseInt(settings.rtspChannel?.toString()) || 0;
const api = this.getClient();
try {
await api.jpegSnapshot(rtspChannel);
}
catch (e) {
this.getLogger().error('Error adding Reolink camera', e);
throw e;
}
const foundName = this.storageSettings.values.devicesData[rtspChannel]?.channelStatus?.name;
settings.newCamera ||= foundName ?? 'Reolink Camera';
nativeId = await super.createDevice(settings, nativeId);
const device = await this.getDevice(nativeId) as ReolinkCamera;
device.info = info;
device.storageSettings.values.rtspChannel = rtspChannel;
device.updateDeviceInfo();
this.cameraChannelMap.set(String(rtspChannel), device);
return nativeId;
}
async releaseDevice(id: string, nativeId: string) {
this.cameraChannelMap.delete(id);
this.devices.delete(id);
}
async getCreateDeviceSettings(): Promise<Setting[]> {
return [
{
key: 'rtspChannel',
title: 'Channel Number Override',
description: "The channel number to use for snapshots and video. E.g., 0, 1, 2, etc.",
placeholder: '0',
type: 'number',
}
]
}
createCamera(nativeId: string) {
try {
return new ReolinkCamera(nativeId, this);
} catch (e) {
this.getLogger().error('Error creating device', nativeId, e)
}
}
}
export default ReolinkProvider;