Files
integrations/ts/integrations/android_ip_webcam/android_ip_webcam.classes.client.ts
T

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;
}
};