530 lines
21 KiB
TypeScript
530 lines
21 KiB
TypeScript
import type {
|
|
IAndroidIpWebcamBinarySensor,
|
|
IAndroidIpWebcamCamera,
|
|
IAndroidIpWebcamClientCommand,
|
|
IAndroidIpWebcamConfig,
|
|
IAndroidIpWebcamDeviceInfo,
|
|
IAndroidIpWebcamSensor,
|
|
IAndroidIpWebcamSensorData,
|
|
IAndroidIpWebcamSnapshot,
|
|
IAndroidIpWebcamSnapshotImage,
|
|
IAndroidIpWebcamStatusData,
|
|
IAndroidIpWebcamSwitch,
|
|
TAndroidIpWebcamOrientation,
|
|
TAndroidIpWebcamProtocol,
|
|
TAndroidIpWebcamRtspAudioCodec,
|
|
TAndroidIpWebcamRtspVideoCodec,
|
|
} from './android_ip_webcam.types.js';
|
|
import {
|
|
androidIpWebcamDefaultPort,
|
|
androidIpWebcamDefaultTimeoutMs,
|
|
androidIpWebcamSensorDescriptions,
|
|
androidIpWebcamSwitchDescriptions,
|
|
} from './android_ip_webcam.types.js';
|
|
|
|
const allowedOrientations = new Set<TAndroidIpWebcamOrientation>(['landscape', 'upsidedown', 'portrait', 'upsidedown_portrait']);
|
|
|
|
export class AndroidIpWebcamHttpError extends Error {
|
|
constructor(public readonly status: number, messageArg: string) {
|
|
super(messageArg);
|
|
this.name = 'AndroidIpWebcamHttpError';
|
|
}
|
|
}
|
|
|
|
export class AndroidIpWebcamClient {
|
|
private snapshot?: IAndroidIpWebcamSnapshot;
|
|
|
|
constructor(private readonly config: IAndroidIpWebcamConfig) {}
|
|
|
|
public async getSnapshot(forceRefreshArg = false): Promise<IAndroidIpWebcamSnapshot> {
|
|
if (!forceRefreshArg && this.snapshot) {
|
|
return this.snapshot;
|
|
}
|
|
|
|
if (!forceRefreshArg && this.config.snapshot) {
|
|
this.snapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
|
|
return this.snapshot;
|
|
}
|
|
|
|
if (this.hasLiveTarget()) {
|
|
try {
|
|
this.snapshot = await this.fetchSnapshot();
|
|
return this.snapshot;
|
|
} catch (errorArg) {
|
|
this.snapshot = this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg));
|
|
return this.snapshot;
|
|
}
|
|
}
|
|
|
|
this.snapshot = this.snapshotFromConfig(this.config.connected ?? false);
|
|
return this.snapshot;
|
|
}
|
|
|
|
public async validateConnection(): Promise<void> {
|
|
await this.fetchSnapshot();
|
|
}
|
|
|
|
public async execute(commandArg: IAndroidIpWebcamClientCommand): Promise<unknown> {
|
|
if (commandArg.type === 'refresh') {
|
|
return this.getSnapshot(true);
|
|
}
|
|
if (commandArg.type === 'stream_source') {
|
|
return {
|
|
streamSource: this.rtspUrl(commandArg.videoCodec || 'h264', commandArg.audioCodec || 'aac'),
|
|
mjpegUrl: this.mjpegUrl(),
|
|
stillImageUrl: this.imageUrl(),
|
|
verified: false,
|
|
};
|
|
}
|
|
if (commandArg.type === 'snapshot_image') {
|
|
if (commandArg.filename) {
|
|
throw new Error('Android IP Webcam snapshot file writes are not implemented; request data as base64 without data.filename.');
|
|
}
|
|
const image = await this.getSnapshotImage();
|
|
return {
|
|
contentType: image.contentType,
|
|
dataBase64: Buffer.from(image.data).toString('base64'),
|
|
};
|
|
}
|
|
if (commandArg.type === 'setting') {
|
|
if (!commandArg.key) {
|
|
throw new Error('Android IP Webcam setting command requires a key.');
|
|
}
|
|
await this.changeSetting(commandArg.key, commandArg.value);
|
|
return { ok: true, key: commandArg.key, value: commandArg.value };
|
|
}
|
|
if (commandArg.type === 'torch') {
|
|
await this.getOk(commandArg.activate === false ? '/disabletorch' : '/enabletorch', 'torch');
|
|
this.patchCachedSetting('torch', commandArg.activate !== false);
|
|
return { ok: true, key: 'torch', value: commandArg.activate !== false };
|
|
}
|
|
if (commandArg.type === 'focus') {
|
|
await this.getOk(commandArg.activate === false ? '/nofocus' : '/focus', 'focus');
|
|
this.patchCachedSetting('focus', commandArg.activate !== false);
|
|
return { ok: true, key: 'focus', value: commandArg.activate !== false };
|
|
}
|
|
if (commandArg.type === 'record') {
|
|
await this.record(commandArg.record !== false, commandArg.tag);
|
|
return { ok: true, key: 'video_recording', value: commandArg.record !== false };
|
|
}
|
|
if (commandArg.type === 'set_zoom') {
|
|
if (typeof commandArg.zoom !== 'number' || !Number.isFinite(commandArg.zoom)) {
|
|
throw new Error('Android IP Webcam set_zoom requires a numeric zoom value.');
|
|
}
|
|
await this.getOk(`/settings/ptz?zoom=${encodeURIComponent(String(Math.round(commandArg.zoom)))}`, 'set_zoom');
|
|
return { ok: true, key: 'zoom', value: Math.round(commandArg.zoom) };
|
|
}
|
|
if (commandArg.type === 'set_quality') {
|
|
if (typeof commandArg.quality !== 'number' || !Number.isFinite(commandArg.quality)) {
|
|
throw new Error('Android IP Webcam set_quality requires a numeric quality value.');
|
|
}
|
|
const quality = Math.max(0, Math.min(100, Math.round(commandArg.quality)));
|
|
await this.changeSetting('quality', quality);
|
|
return { ok: true, key: 'quality', value: quality };
|
|
}
|
|
if (commandArg.type === 'set_orientation') {
|
|
if (!commandArg.orientation || !allowedOrientations.has(commandArg.orientation)) {
|
|
throw new Error('Android IP Webcam set_orientation requires a supported orientation.');
|
|
}
|
|
await this.changeSetting('orientation', commandArg.orientation);
|
|
return { ok: true, key: 'orientation', value: commandArg.orientation };
|
|
}
|
|
if (commandArg.type === 'set_scenemode') {
|
|
if (!commandArg.scenemode) {
|
|
throw new Error('Android IP Webcam set_scenemode requires a scenemode value.');
|
|
}
|
|
await this.changeSetting('scenemode', commandArg.scenemode);
|
|
return { ok: true, key: 'scenemode', value: commandArg.scenemode };
|
|
}
|
|
throw new Error(`Unsupported Android IP Webcam command: ${commandArg.type}`);
|
|
}
|
|
|
|
public async getSnapshotImage(): Promise<IAndroidIpWebcamSnapshotImage> {
|
|
const response = await this.request('/shot.jpg');
|
|
return {
|
|
contentType: response.headers.get('content-type') || 'image/jpeg',
|
|
data: new Uint8Array(await response.arrayBuffer()),
|
|
};
|
|
}
|
|
|
|
public async destroy(): Promise<void> {}
|
|
|
|
private async fetchSnapshot(): Promise<IAndroidIpWebcamSnapshot> {
|
|
const [statusData, sensorData] = await Promise.all([
|
|
this.requestJson<IAndroidIpWebcamStatusData>('/status.json?show_avail=1'),
|
|
this.requestJson<IAndroidIpWebcamSensorData>('/sensors.json'),
|
|
]);
|
|
return this.snapshotFromData(statusData, sensorData, true);
|
|
}
|
|
|
|
private snapshotFromData(statusDataArg: IAndroidIpWebcamStatusData, sensorDataArg: IAndroidIpWebcamSensorData, connectedArg: boolean): IAndroidIpWebcamSnapshot {
|
|
const currentSettings = this.currentSettings(statusDataArg.curvals || this.config.currentSettings || {});
|
|
const enabledSensors = this.config.enabledSensors || Object.keys(sensorDataArg);
|
|
const enabledSettings = this.config.enabledSettings || Object.keys(currentSettings);
|
|
const availableSettings = this.availableSettings(statusDataArg.avail || this.config.availableSettings || {});
|
|
const deviceInfo = this.deviceInfo(connectedArg);
|
|
const snapshot: IAndroidIpWebcamSnapshot = {
|
|
deviceInfo,
|
|
camera: this.camera(deviceInfo, connectedArg),
|
|
sensors: this.config.sensors || this.sensors(statusDataArg, sensorDataArg, enabledSensors, connectedArg),
|
|
binarySensors: this.config.binarySensors || this.binarySensors(sensorDataArg, enabledSensors, connectedArg),
|
|
switches: this.config.switches || this.switches(currentSettings, enabledSettings, connectedArg),
|
|
statusData: statusDataArg,
|
|
sensorData: sensorDataArg,
|
|
currentSettings,
|
|
enabledSensors,
|
|
enabledSettings,
|
|
availableSettings,
|
|
connected: connectedArg,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
return this.normalizeSnapshot(snapshot);
|
|
}
|
|
|
|
private snapshotFromConfig(connectedArg: boolean, lastErrorArg?: string): IAndroidIpWebcamSnapshot {
|
|
const statusData = this.config.statusData || this.config.snapshot?.statusData || {};
|
|
const sensorData = this.config.sensorData || this.config.snapshot?.sensorData || {};
|
|
const currentSettings = this.currentSettings(this.config.currentSettings || this.config.snapshot?.currentSettings || statusData.curvals || {});
|
|
const enabledSensors = this.config.enabledSensors || this.config.snapshot?.enabledSensors || Object.keys(sensorData);
|
|
const enabledSettings = this.config.enabledSettings || this.config.snapshot?.enabledSettings || Object.keys(currentSettings);
|
|
const availableSettings = this.availableSettings(this.config.availableSettings || this.config.snapshot?.availableSettings || statusData.avail || {});
|
|
const deviceInfo = this.deviceInfo(connectedArg);
|
|
return this.normalizeSnapshot({
|
|
deviceInfo,
|
|
camera: this.config.camera || this.config.snapshot?.camera || this.camera(deviceInfo, connectedArg),
|
|
sensors: this.config.sensors || this.config.snapshot?.sensors || this.sensors(statusData, sensorData, enabledSensors, connectedArg),
|
|
binarySensors: this.config.binarySensors || this.config.snapshot?.binarySensors || this.binarySensors(sensorData, enabledSensors, connectedArg),
|
|
switches: this.config.switches || this.config.snapshot?.switches || this.switches(currentSettings, enabledSettings, connectedArg),
|
|
statusData,
|
|
sensorData,
|
|
currentSettings,
|
|
enabledSensors,
|
|
enabledSettings,
|
|
availableSettings,
|
|
connected: connectedArg,
|
|
updatedAt: new Date().toISOString(),
|
|
metadata: {
|
|
...this.config.snapshot?.metadata,
|
|
lastLiveError: lastErrorArg,
|
|
},
|
|
});
|
|
}
|
|
|
|
private normalizeSnapshot(snapshotArg: IAndroidIpWebcamSnapshot): IAndroidIpWebcamSnapshot {
|
|
const connected = Boolean(snapshotArg.connected && snapshotArg.deviceInfo.online !== false);
|
|
const deviceInfo = {
|
|
...this.deviceInfo(connected),
|
|
...snapshotArg.deviceInfo,
|
|
online: connected,
|
|
};
|
|
return {
|
|
...snapshotArg,
|
|
deviceInfo,
|
|
camera: {
|
|
...this.camera(deviceInfo, connected),
|
|
...snapshotArg.camera,
|
|
available: connected && snapshotArg.camera.available !== false,
|
|
},
|
|
sensors: snapshotArg.sensors || [],
|
|
binarySensors: snapshotArg.binarySensors || [],
|
|
switches: snapshotArg.switches || [],
|
|
statusData: snapshotArg.statusData || {},
|
|
sensorData: snapshotArg.sensorData || {},
|
|
currentSettings: snapshotArg.currentSettings || {},
|
|
enabledSensors: snapshotArg.enabledSensors || [],
|
|
enabledSettings: snapshotArg.enabledSettings || [],
|
|
availableSettings: snapshotArg.availableSettings || {},
|
|
connected,
|
|
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
private sensors(statusDataArg: IAndroidIpWebcamStatusData, sensorDataArg: IAndroidIpWebcamSensorData, enabledSensorsArg: string[], connectedArg: boolean): IAndroidIpWebcamSensor[] {
|
|
const enabled = new Set(enabledSensorsArg);
|
|
const sensors: IAndroidIpWebcamSensor[] = [];
|
|
for (const description of androidIpWebcamSensorDescriptions) {
|
|
const isConnectionSensor = Boolean(description.statusKey);
|
|
if (!isConnectionSensor && !enabled.has(description.key)) {
|
|
continue;
|
|
}
|
|
const sensorDatum = sensorDataArg[description.key];
|
|
const value = description.statusKey ? statusDataArg[description.statusKey] : this.sensorValue(sensorDatum);
|
|
sensors.push({
|
|
key: description.key,
|
|
name: description.name,
|
|
value,
|
|
unit: sensorDatum?.unit,
|
|
deviceClass: description.deviceClass,
|
|
stateClass: description.stateClass,
|
|
entityCategory: description.entityCategory,
|
|
available: connectedArg && (isConnectionSensor || enabled.has(description.key)),
|
|
});
|
|
}
|
|
return sensors;
|
|
}
|
|
|
|
private binarySensors(sensorDataArg: IAndroidIpWebcamSensorData, enabledSensorsArg: string[], connectedArg: boolean): IAndroidIpWebcamBinarySensor[] {
|
|
const enabled = enabledSensorsArg.includes('motion_active');
|
|
return [{
|
|
key: 'motion_active',
|
|
name: 'Motion active',
|
|
isOn: this.sensorValue(sensorDataArg.motion_active) === 1 || this.sensorValue(sensorDataArg.motion_active) === 1.0,
|
|
deviceClass: 'motion',
|
|
available: connectedArg && enabled,
|
|
}];
|
|
}
|
|
|
|
private switches(currentSettingsArg: Record<string, unknown>, enabledSettingsArg: string[], connectedArg: boolean): IAndroidIpWebcamSwitch[] {
|
|
const enabled = new Set(enabledSettingsArg);
|
|
return androidIpWebcamSwitchDescriptions
|
|
.filter((descriptionArg) => enabled.has(descriptionArg.key))
|
|
.map((descriptionArg): IAndroidIpWebcamSwitch => ({
|
|
key: descriptionArg.key,
|
|
name: descriptionArg.name,
|
|
isOn: Boolean(currentSettingsArg[descriptionArg.key]),
|
|
command: descriptionArg.command,
|
|
entityCategory: descriptionArg.entityCategory,
|
|
available: connectedArg,
|
|
}));
|
|
}
|
|
|
|
private sensorValue(sensorDatumArg: unknown): unknown {
|
|
const data = record(sensorDatumArg)?.data;
|
|
if (!Array.isArray(data) || !data.length) {
|
|
return undefined;
|
|
}
|
|
const series = data[data.length - 1];
|
|
if (!Array.isArray(series) || !series.length) {
|
|
return series;
|
|
}
|
|
const sample = series[series.length - 1];
|
|
if (Array.isArray(sample)) {
|
|
return sample[0];
|
|
}
|
|
return sample;
|
|
}
|
|
|
|
private currentSettings(valuesArg: Record<string, unknown>): Record<string, unknown> {
|
|
return Object.fromEntries(Object.entries(valuesArg).map(([keyArg, valueArg]) => [keyArg, this.settingValue(valueArg)]));
|
|
}
|
|
|
|
private availableSettings(valuesArg: Record<string, unknown[]>): Record<string, unknown[]> {
|
|
return Object.fromEntries(Object.entries(valuesArg).map(([keyArg, valueArg]) => [keyArg, Array.isArray(valueArg) ? valueArg.map((itemArg) => this.settingValue(itemArg)) : []]));
|
|
}
|
|
|
|
private settingValue(valueArg: unknown): unknown {
|
|
if (typeof valueArg !== 'string') {
|
|
return valueArg;
|
|
}
|
|
const value = valueArg.trim();
|
|
if (value === 'on') {
|
|
return true;
|
|
}
|
|
if (value === 'off') {
|
|
return false;
|
|
}
|
|
if (/^-?\d+(?:\.\d+)?$/.test(value)) {
|
|
return Number(value);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
private async changeSetting(keyArg: string, valueArg: unknown): Promise<void> {
|
|
const payload = typeof valueArg === 'boolean' ? valueArg ? 'on' : 'off' : String(valueArg ?? '');
|
|
await this.getOk(`/settings/${encodeURIComponent(keyArg)}?set=${encodeURIComponent(payload)}`, `change_setting ${keyArg}`);
|
|
this.patchCachedSetting(keyArg, valueArg);
|
|
}
|
|
|
|
private async record(recordArg: boolean, tagArg?: string): Promise<void> {
|
|
const tag = tagArg ? `&tag=${encodeURIComponent(tagArg)}` : '';
|
|
await this.getOk(recordArg ? `/startvideo?force=1${tag}` : '/stopvideo?force=1', 'record');
|
|
this.patchCachedSetting('video_recording', recordArg);
|
|
}
|
|
|
|
private async getOk(pathArg: string, actionArg: string): Promise<void> {
|
|
const text = await (await this.request(pathArg)).text();
|
|
if (!text.includes('Ok')) {
|
|
throw new Error(`Android IP Webcam ${actionArg} did not return Ok.`);
|
|
}
|
|
}
|
|
|
|
private async requestJson<TValue>(pathArg: string): Promise<TValue> {
|
|
const value = await (await this.request(pathArg)).json();
|
|
return record(value) ? value as TValue : {} as TValue;
|
|
}
|
|
|
|
private async request(pathArg: string): Promise<Response> {
|
|
const baseUrl = this.baseUrl();
|
|
if (!baseUrl) {
|
|
throw new Error('Android IP Webcam live HTTP client requires config.host or config.url.');
|
|
}
|
|
const headers = new Headers();
|
|
const authorization = this.basicAuthorization();
|
|
if (authorization) {
|
|
headers.set('authorization', authorization);
|
|
}
|
|
const response = await this.fetchWithTimeout(`${baseUrl}${pathArg.startsWith('/') ? pathArg : `/${pathArg}`}`, { method: 'GET', headers });
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
if (response.status === 401) {
|
|
throw new AndroidIpWebcamHttpError(response.status, 'Android IP Webcam authentication failed.');
|
|
}
|
|
throw new AndroidIpWebcamHttpError(response.status, `Android IP Webcam request ${pathArg} failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
|
|
}
|
|
return response;
|
|
}
|
|
|
|
private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise<Response> {
|
|
const abortController = new AbortController();
|
|
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || androidIpWebcamDefaultTimeoutMs);
|
|
try {
|
|
return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
private patchCachedSetting(keyArg: string, valueArg: unknown): void {
|
|
if (!this.snapshot) {
|
|
return;
|
|
}
|
|
this.snapshot.currentSettings[keyArg] = valueArg;
|
|
for (const switchEntity of this.snapshot.switches) {
|
|
if (switchEntity.key === keyArg) {
|
|
switchEntity.isOn = Boolean(valueArg);
|
|
}
|
|
}
|
|
}
|
|
|
|
private camera(deviceInfoArg: IAndroidIpWebcamDeviceInfo, connectedArg: boolean): IAndroidIpWebcamCamera {
|
|
return {
|
|
id: 'camera',
|
|
name: `${deviceInfoArg.name || 'Android IP Webcam'} Camera`,
|
|
mjpegUrl: this.mjpegUrl(),
|
|
imageUrl: this.imageUrl(),
|
|
rtspUrl: this.safeRtspUrl('h264', 'aac'),
|
|
audioWavUrl: this.audioUrl('audio.wav'),
|
|
audioAacUrl: this.audioUrl('audio.aac'),
|
|
audioOpusUrl: this.audioUrl('audio.opus'),
|
|
supportedFeatures: ['stream'],
|
|
available: connectedArg,
|
|
};
|
|
}
|
|
|
|
private deviceInfo(connectedArg: boolean): IAndroidIpWebcamDeviceInfo {
|
|
const endpoint = this.endpoint();
|
|
return {
|
|
...this.config.deviceInfo,
|
|
id: this.config.deviceInfo?.id || this.config.uniqueId || endpoint.host || this.config.url || 'manual-android-ip-webcam',
|
|
name: this.config.deviceInfo?.name || this.config.name || endpoint.host || this.config.url || 'Android IP Webcam',
|
|
manufacturer: this.config.deviceInfo?.manufacturer || this.config.manufacturer || 'Android IP Webcam',
|
|
model: this.config.deviceInfo?.model || this.config.model,
|
|
host: this.config.deviceInfo?.host || endpoint.host,
|
|
port: this.config.deviceInfo?.port || endpoint.port,
|
|
protocol: this.config.deviceInfo?.protocol || endpoint.protocol,
|
|
url: this.config.deviceInfo?.url || this.baseUrl(),
|
|
online: connectedArg,
|
|
};
|
|
}
|
|
|
|
private mjpegUrl(): string | undefined {
|
|
const baseUrl = this.baseUrl();
|
|
return baseUrl ? `${baseUrl}/video` : undefined;
|
|
}
|
|
|
|
private imageUrl(): string | undefined {
|
|
const baseUrl = this.baseUrl();
|
|
return baseUrl ? `${baseUrl}/shot.jpg` : undefined;
|
|
}
|
|
|
|
private audioUrl(pathArg: 'audio.wav' | 'audio.aac' | 'audio.opus'): string | undefined {
|
|
const baseUrl = this.baseUrl();
|
|
return baseUrl ? `${baseUrl}/${pathArg}` : undefined;
|
|
}
|
|
|
|
private rtspUrl(videoCodecArg: TAndroidIpWebcamRtspVideoCodec, audioCodecArg: TAndroidIpWebcamRtspAudioCodec): string | undefined {
|
|
const endpoint = this.endpoint();
|
|
if (!endpoint.host) {
|
|
throw new Error('Android IP Webcam stream_source requires config.host or config.url.');
|
|
}
|
|
const protocol = endpoint.protocol === 'https' ? 'rtsps' : 'rtsp';
|
|
const credentials = this.rtspCredentials();
|
|
return `${protocol}://${credentials}${endpoint.host}:${endpoint.port || androidIpWebcamDefaultPort}/${videoCodecArg}_${audioCodecArg}.sdp`;
|
|
}
|
|
|
|
private safeRtspUrl(videoCodecArg: TAndroidIpWebcamRtspVideoCodec, audioCodecArg: TAndroidIpWebcamRtspAudioCodec): string | undefined {
|
|
try {
|
|
return this.rtspUrl(videoCodecArg, audioCodecArg);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private baseUrl(): string | undefined {
|
|
if (this.config.url) {
|
|
const url = safeUrl(this.config.url);
|
|
if (url) {
|
|
return url.toString().replace(/\/$/, '');
|
|
}
|
|
}
|
|
const endpoint = this.endpoint();
|
|
if (!endpoint.host) {
|
|
return undefined;
|
|
}
|
|
return `${endpoint.protocol}://${endpoint.host}:${endpoint.port || androidIpWebcamDefaultPort}`;
|
|
}
|
|
|
|
private endpoint(): { protocol: TAndroidIpWebcamProtocol; host?: string; port?: number } {
|
|
const url = safeUrl(this.config.url || this.config.host);
|
|
if (url) {
|
|
return {
|
|
protocol: url.protocol === 'https:' ? 'https' : 'http',
|
|
host: url.hostname,
|
|
port: url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : androidIpWebcamDefaultPort,
|
|
};
|
|
}
|
|
return {
|
|
protocol: this.config.protocol || 'http',
|
|
host: this.config.host,
|
|
port: this.config.port || androidIpWebcamDefaultPort,
|
|
};
|
|
}
|
|
|
|
private basicAuthorization(): string | undefined {
|
|
if (!this.config.username || this.config.password === undefined) {
|
|
return undefined;
|
|
}
|
|
return `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`, 'utf8').toString('base64')}`;
|
|
}
|
|
|
|
private rtspCredentials(): string {
|
|
if (!this.config.username || this.config.password === undefined) {
|
|
return '';
|
|
}
|
|
return `${encodeURIComponent(this.config.username)}:${encodeURIComponent(this.config.password)}@`;
|
|
}
|
|
|
|
private hasLiveTarget(): boolean {
|
|
return Boolean(this.baseUrl());
|
|
}
|
|
|
|
private cloneSnapshot(snapshotArg: IAndroidIpWebcamSnapshot): IAndroidIpWebcamSnapshot {
|
|
return JSON.parse(JSON.stringify(snapshotArg)) as IAndroidIpWebcamSnapshot;
|
|
}
|
|
}
|
|
|
|
const record = (valueArg: unknown): Record<string, unknown> | undefined => {
|
|
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
|
|
};
|
|
|
|
const safeUrl = (valueArg: string | undefined): URL | undefined => {
|
|
if (!valueArg) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return new URL(valueArg);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|