Add native local network integrations
This commit is contained in:
+12
@@ -3,12 +3,18 @@ export * from './protocols/index.js';
|
||||
export * from './integrations/index.js';
|
||||
|
||||
import { HueIntegration } from './integrations/hue/index.js';
|
||||
import { AdguardIntegration } from './integrations/adguard/index.js';
|
||||
import { AirgradientIntegration } from './integrations/airgradient/index.js';
|
||||
import { AmcrestIntegration } from './integrations/amcrest/index.js';
|
||||
import { AndroidIpWebcamIntegration } from './integrations/android_ip_webcam/index.js';
|
||||
import { AndroidtvIntegration } from './integrations/androidtv/index.js';
|
||||
import { AndroidtvRemoteIntegration } from './integrations/androidtv_remote/index.js';
|
||||
import { AxisIntegration } from './integrations/axis/index.js';
|
||||
import { ApcupsdIntegration } from './integrations/apcupsd/index.js';
|
||||
import { ArcamFmjIntegration } from './integrations/arcam_fmj/index.js';
|
||||
import { AsuswrtIntegration } from './integrations/asuswrt/index.js';
|
||||
import { BleboxIntegration } from './integrations/blebox/index.js';
|
||||
import { BluetoothLeTrackerIntegration } from './integrations/bluetooth_le_tracker/index.js';
|
||||
import { BraviatvIntegration } from './integrations/braviatv/index.js';
|
||||
import { BroadlinkIntegration } from './integrations/broadlink/index.js';
|
||||
import { CastIntegration } from './integrations/cast/index.js';
|
||||
@@ -53,12 +59,18 @@ import { generatedHomeAssistantPortIntegrations } from './integrations/generated
|
||||
import { IntegrationRegistry } from './core/index.js';
|
||||
|
||||
export const integrations = [
|
||||
new AdguardIntegration(),
|
||||
new AirgradientIntegration(),
|
||||
new AmcrestIntegration(),
|
||||
new AndroidIpWebcamIntegration(),
|
||||
new AndroidtvIntegration(),
|
||||
new AndroidtvRemoteIntegration(),
|
||||
new ApcupsdIntegration(),
|
||||
new ArcamFmjIntegration(),
|
||||
new AsuswrtIntegration(),
|
||||
new AxisIntegration(),
|
||||
new BleboxIntegration(),
|
||||
new BluetoothLeTrackerIntegration(),
|
||||
new BraviatvIntegration(),
|
||||
new BroadlinkIntegration(),
|
||||
new CastIntegration(),
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,216 @@
|
||||
import type {
|
||||
IAdguardBooleanStatus,
|
||||
IAdguardClientCommand,
|
||||
IAdguardCommandResult,
|
||||
IAdguardConfig,
|
||||
IAdguardFilteringStatus,
|
||||
IAdguardQueryLogConfig,
|
||||
IAdguardSafeSearchConfig,
|
||||
IAdguardServerStatus,
|
||||
IAdguardSnapshot,
|
||||
IAdguardStats,
|
||||
IAdguardVersionInfo,
|
||||
TAdguardJsonValue,
|
||||
TAdguardSnapshotSource,
|
||||
} from './adguard.types.js';
|
||||
import { adguardDefaultPort, adguardDefaultTimeoutMs } from './adguard.types.js';
|
||||
|
||||
export class AdguardClient {
|
||||
constructor(private readonly config: IAdguardConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<IAdguardSnapshot> {
|
||||
if (this.config.snapshot) {
|
||||
return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot), 'snapshot');
|
||||
}
|
||||
|
||||
if (this.config.host) {
|
||||
try {
|
||||
return this.normalizeSnapshot(await this.fetchSnapshot(), 'http');
|
||||
} catch (errorArg) {
|
||||
return this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime');
|
||||
}
|
||||
}
|
||||
|
||||
return this.normalizeSnapshot(this.snapshotFromConfig(false), 'manual');
|
||||
}
|
||||
|
||||
public async refresh(): Promise<IAdguardSnapshot> {
|
||||
return this.getSnapshot();
|
||||
}
|
||||
|
||||
public async ping(): Promise<boolean> {
|
||||
if (!this.config.host) {
|
||||
return Boolean(this.config.snapshot);
|
||||
}
|
||||
const status = await this.requestJson<IAdguardServerStatus>('/status');
|
||||
return status.running !== false;
|
||||
}
|
||||
|
||||
public async sendCommand(commandArg: IAdguardClientCommand): Promise<IAdguardCommandResult> {
|
||||
if (this.config.commandExecutor) {
|
||||
return this.commandResult(await this.config.commandExecutor(commandArg), commandArg);
|
||||
}
|
||||
|
||||
if (!this.config.host) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'AdGuard Home live commands require config.host or commandExecutor; snapshot-only commands are not reported as successful.',
|
||||
data: { command: commandArg },
|
||||
};
|
||||
}
|
||||
|
||||
const data = await this.requestJson<TAdguardJsonValue>(commandArg.path, {
|
||||
method: commandArg.method,
|
||||
body: commandArg.payload,
|
||||
});
|
||||
return { success: true, data: { command: commandArg, response: data } };
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async fetchSnapshot(): Promise<IAdguardSnapshot> {
|
||||
const status = await this.requestJson<IAdguardServerStatus>('/status');
|
||||
const [filtering, queryLog, safebrowsing, safesearch, parental, stats, update] = await Promise.all([
|
||||
this.requestJson<IAdguardFilteringStatus>('/filtering/status').catch(() => ({})),
|
||||
this.requestJson<IAdguardQueryLogConfig>('/querylog/config').catch(() => this.requestJson<IAdguardQueryLogConfig>('/querylog_info').catch(() => ({}))),
|
||||
this.requestJson<IAdguardBooleanStatus>('/safebrowsing/status').catch(() => ({})),
|
||||
this.requestJson<IAdguardSafeSearchConfig>('/safesearch/status').catch(() => ({})),
|
||||
this.requestJson<IAdguardBooleanStatus>('/parental/status').catch(() => ({})),
|
||||
this.requestJson<IAdguardStats>('/stats').catch(() => ({})),
|
||||
this.requestJson<IAdguardVersionInfo>('/version.json', { method: 'POST', body: { recheck_now: false } }).catch(() => undefined),
|
||||
]);
|
||||
|
||||
return {
|
||||
online: status.running !== false,
|
||||
status,
|
||||
filtering,
|
||||
queryLog,
|
||||
safebrowsing,
|
||||
safesearch,
|
||||
parental,
|
||||
stats,
|
||||
update,
|
||||
host: this.config.host,
|
||||
port: this.config.port || status.http_port || adguardDefaultPort,
|
||||
ssl: this.config.ssl ?? false,
|
||||
basePath: this.config.basePath,
|
||||
name: this.config.name,
|
||||
uniqueId: this.config.uniqueId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: 'http',
|
||||
};
|
||||
}
|
||||
|
||||
private async requestJson<TResponse>(pathArg: string, optionsArg: { method?: 'GET' | 'POST' | 'PUT'; body?: unknown } = {}): Promise<TResponse> {
|
||||
const method = optionsArg.method || 'GET';
|
||||
const headers: Record<string, string> = {};
|
||||
let body: string | undefined;
|
||||
if (optionsArg.body !== undefined) {
|
||||
headers['content-type'] = 'application/json';
|
||||
body = JSON.stringify(optionsArg.body);
|
||||
}
|
||||
if (this.config.username || this.config.password) {
|
||||
headers.authorization = `Basic ${Buffer.from(`${this.config.username || ''}:${this.config.password || ''}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
const response = await globalThis.fetch(`${this.controlUrl()}${pathArg}`, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(this.config.timeoutMs || adguardDefaultTimeoutMs),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`AdGuard Home request ${pathArg} failed with HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
if (!text) {
|
||||
return {} as TResponse;
|
||||
}
|
||||
return JSON.parse(text) as TResponse;
|
||||
}
|
||||
|
||||
private controlUrl(): string {
|
||||
if (!this.config.host) {
|
||||
throw new Error('AdGuard Home host is required for HTTP API access.');
|
||||
}
|
||||
const protocol = this.config.ssl ? 'https' : 'http';
|
||||
const port = this.config.port || adguardDefaultPort;
|
||||
const defaultPort = protocol === 'https' ? 443 : 80;
|
||||
const portPart = port === defaultPort ? '' : `:${port}`;
|
||||
const basePath = this.normalizeBasePath(this.config.basePath);
|
||||
return `${protocol}://${this.config.host}${portPart}${basePath}/control`;
|
||||
}
|
||||
|
||||
private normalizeBasePath(valueArg: string | undefined): string {
|
||||
if (!valueArg) {
|
||||
return '';
|
||||
}
|
||||
const trimmed = valueArg.trim().replace(/\/+$/g, '');
|
||||
if (!trimmed || trimmed === '/') {
|
||||
return '';
|
||||
}
|
||||
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
private snapshotFromConfig(onlineArg: boolean, errorArg?: string): IAdguardSnapshot {
|
||||
return {
|
||||
online: onlineArg,
|
||||
status: {
|
||||
running: onlineArg,
|
||||
version: typeof this.config.metadata?.version === 'string' ? this.config.metadata.version : undefined,
|
||||
},
|
||||
filtering: {},
|
||||
queryLog: {},
|
||||
safebrowsing: {},
|
||||
safesearch: {},
|
||||
parental: {},
|
||||
stats: {},
|
||||
host: this.config.host,
|
||||
port: this.config.port || (this.config.host ? adguardDefaultPort : undefined),
|
||||
ssl: this.config.ssl ?? false,
|
||||
basePath: this.config.basePath,
|
||||
name: this.config.name,
|
||||
uniqueId: this.config.uniqueId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: 'runtime',
|
||||
error: errorArg,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IAdguardSnapshot, sourceArg: TAdguardSnapshotSource): IAdguardSnapshot {
|
||||
return {
|
||||
...snapshotArg,
|
||||
online: snapshotArg.online,
|
||||
status: snapshotArg.status || {},
|
||||
filtering: snapshotArg.filtering || {},
|
||||
queryLog: snapshotArg.queryLog || {},
|
||||
safebrowsing: snapshotArg.safebrowsing || {},
|
||||
safesearch: snapshotArg.safesearch || {},
|
||||
parental: snapshotArg.parental || {},
|
||||
stats: snapshotArg.stats || {},
|
||||
host: snapshotArg.host || this.config.host,
|
||||
port: snapshotArg.port || this.config.port || (snapshotArg.host || this.config.host ? adguardDefaultPort : undefined),
|
||||
ssl: snapshotArg.ssl ?? this.config.ssl ?? false,
|
||||
basePath: snapshotArg.basePath ?? this.config.basePath,
|
||||
name: snapshotArg.name || this.config.name,
|
||||
uniqueId: snapshotArg.uniqueId || this.config.uniqueId,
|
||||
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||
source: snapshotArg.source || sourceArg,
|
||||
};
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IAdguardSnapshot): IAdguardSnapshot {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as IAdguardSnapshot;
|
||||
}
|
||||
|
||||
private commandResult(resultArg: unknown, commandArg: IAdguardClientCommand): IAdguardCommandResult {
|
||||
if (this.isCommandResult(resultArg)) {
|
||||
return resultArg;
|
||||
}
|
||||
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||
}
|
||||
|
||||
private isCommandResult(valueArg: unknown): valueArg is IAdguardCommandResult {
|
||||
return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IAdguardConfig, IAdguardSnapshot } from './adguard.types.js';
|
||||
import { adguardDefaultPort } from './adguard.types.js';
|
||||
|
||||
export class AdguardConfigFlow implements IConfigFlow<IAdguardConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAdguardConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Set up AdGuard Home',
|
||||
description: 'Connect to a local AdGuard Home HTTP API. This flow validates configuration shape and keeps live command success tied to a real HTTP client or command executor.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host or URL', type: 'text', required: true },
|
||||
{ name: 'port', label: 'Port', type: 'number' },
|
||||
{ name: 'ssl', label: 'Use HTTPS', type: 'boolean' },
|
||||
{ name: 'verifySsl', label: 'Verify TLS certificate', type: 'boolean' },
|
||||
{ name: 'basePath', label: 'Base path', type: 'text' },
|
||||
{ name: 'username', label: 'Username', type: 'text' },
|
||||
{ name: 'password', label: 'Password', type: 'password' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const parsed = parseHostInput(stringValue(valuesArg.host) || candidateArg.host || '');
|
||||
if (!parsed.host) {
|
||||
return { kind: 'error', title: 'Invalid AdGuard Home host', error: 'AdGuard Home setup requires a hostname, IP address, or URL.' };
|
||||
}
|
||||
|
||||
const port = numberValue(valuesArg.port) || parsed.port || candidateArg.port || adguardDefaultPort;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return { kind: 'error', title: 'Invalid AdGuard Home port', error: 'AdGuard Home port must be an integer between 1 and 65535.' };
|
||||
}
|
||||
|
||||
const username = stringValue(valuesArg.username);
|
||||
const password = stringValue(valuesArg.password);
|
||||
if (Boolean(username) !== Boolean(password)) {
|
||||
return { kind: 'error', title: 'Incomplete AdGuard Home credentials', error: 'AdGuard Home username and password must be provided together.' };
|
||||
}
|
||||
|
||||
const ssl = booleanValue(valuesArg.ssl) ?? parsed.ssl ?? booleanValue(candidateArg.metadata?.ssl) ?? false;
|
||||
const verifySsl = booleanValue(valuesArg.verifySsl) ?? booleanValue(candidateArg.metadata?.verifySsl) ?? true;
|
||||
const basePath = stringValue(valuesArg.basePath) || parsed.basePath || stringValue(candidateArg.metadata?.basePath);
|
||||
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'AdGuard Home configured',
|
||||
config: {
|
||||
host: parsed.host,
|
||||
port,
|
||||
ssl,
|
||||
verifySsl,
|
||||
basePath,
|
||||
username,
|
||||
password,
|
||||
name: candidateArg.name || 'AdGuard Home',
|
||||
uniqueId: candidateArg.id,
|
||||
snapshot: isAdguardSnapshot(candidateArg.metadata?.snapshot) ? candidateArg.metadata.snapshot : undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const parseHostInput = (valueArg: string): { host?: string; port?: number; ssl?: boolean; basePath?: string } => {
|
||||
const value = valueArg.trim();
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
if (!value.includes('://')) {
|
||||
return { host: value };
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
ssl: parsed.protocol === 'https:',
|
||||
basePath: parsed.pathname && parsed.pathname !== '/' ? parsed.pathname : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
};
|
||||
|
||||
const numberValue = (valueArg: unknown): number | undefined => {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) return valueArg;
|
||||
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) return Number(valueArg);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const booleanValue = (valueArg: unknown): boolean | undefined => {
|
||||
return typeof valueArg === 'boolean' ? valueArg : undefined;
|
||||
};
|
||||
|
||||
const isAdguardSnapshot = (valueArg: unknown): valueArg is IAdguardSnapshot => {
|
||||
return typeof valueArg === 'object' && valueArg !== null && 'status' in valueArg && 'online' in valueArg;
|
||||
};
|
||||
@@ -1,26 +1,83 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { AdguardClient } from './adguard.classes.client.js';
|
||||
import { AdguardConfigFlow } from './adguard.classes.configflow.js';
|
||||
import { createAdguardDiscoveryDescriptor } from './adguard.discovery.js';
|
||||
import { AdguardMapper } from './adguard.mapper.js';
|
||||
import type { IAdguardConfig } from './adguard.types.js';
|
||||
|
||||
export class HomeAssistantAdguardIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "adguard",
|
||||
displayName: "AdGuard Home",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/adguard",
|
||||
"upstreamDomain": "adguard",
|
||||
"integrationType": "service",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"adguardhome==0.8.1"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@frenck"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class AdguardIntegration extends BaseIntegration<IAdguardConfig> {
|
||||
public readonly domain = 'adguard';
|
||||
public readonly displayName = 'AdGuard Home';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createAdguardDiscoveryDescriptor();
|
||||
public readonly configFlow = new AdguardConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/adguard',
|
||||
upstreamDomain: 'adguard',
|
||||
integrationType: 'service',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['adguardhome==0.8.1'],
|
||||
dependencies: [] as string[],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@frenck'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/adguard',
|
||||
protocolSource: 'AdGuard Home HTTP API under /control: status, filtering, querylog, safebrowsing, safesearch, parental, stats, version, and update endpoints.',
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
polling: 'local HTTP AdGuard Home API',
|
||||
services: ['snapshot', 'status', 'add_url', 'remove_url', 'enable_url', 'disable_url', 'refresh'],
|
||||
controls: ['protection', 'filtering', 'querylog', 'safebrowsing', 'safesearch', 'parental'],
|
||||
liveCommandSuccessRequiresClientOrExecutor: true,
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IAdguardConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new AdguardRuntime(new AdguardClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantAdguardIntegration extends AdguardIntegration {}
|
||||
|
||||
class AdguardRuntime implements IIntegrationRuntime {
|
||||
public domain = 'adguard';
|
||||
|
||||
constructor(private readonly client: AdguardClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return AdguardMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return AdguardMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === 'adguard' && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
|
||||
return { success: true, data: await this.client.getSnapshot() };
|
||||
}
|
||||
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const command = AdguardMapper.commandForService(snapshot, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported AdGuard Home service mapping: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
if ('error' in command) {
|
||||
return { success: false, error: command.error };
|
||||
}
|
||||
return this.client.sendCommand(command);
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { IAdguardManualEntry, IAdguardSnapshot } from './adguard.types.js';
|
||||
import { adguardDefaultPort } from './adguard.types.js';
|
||||
|
||||
export class AdguardManualMatcher implements IDiscoveryMatcher<IAdguardManualEntry> {
|
||||
public id = 'adguard-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual AdGuard Home setup entries by host, domain, manufacturer, model, or metadata.';
|
||||
|
||||
public async matches(inputArg: IAdguardManualEntry): Promise<IDiscoveryMatch> {
|
||||
const matched = isAdguardHint(inputArg) || Boolean(inputArg.host);
|
||||
if (!matched) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: 'low',
|
||||
reason: 'Manual entry does not contain AdGuard Home setup hints.',
|
||||
};
|
||||
}
|
||||
const id = inputArg.id || inputArg.host && `${inputArg.host}:${inputArg.port || adguardDefaultPort}`;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start AdGuard Home setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: 'adguard',
|
||||
id,
|
||||
host: inputArg.host,
|
||||
port: inputArg.port || adguardDefaultPort,
|
||||
name: inputArg.name || 'AdGuard Home',
|
||||
manufacturer: 'AdGuard Team',
|
||||
model: inputArg.model || 'AdGuard Home',
|
||||
metadata: {
|
||||
...inputArg.metadata,
|
||||
ssl: inputArg.ssl,
|
||||
verifySsl: inputArg.verifySsl,
|
||||
basePath: inputArg.basePath,
|
||||
snapshot: inputArg.snapshot,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class AdguardCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'adguard-candidate-validator';
|
||||
public description = 'Validate AdGuard Home candidates before starting local HTTP setup.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const matched = isAdguardHint(candidateArg) || candidateArg.integrationDomain === 'adguard';
|
||||
const snapshot = candidateArg.metadata?.snapshot;
|
||||
const hasSnapshot = isAdguardSnapshot(snapshot);
|
||||
const hasUsableAddress = Boolean(candidateArg.host && isValidPort(candidateArg.port || adguardDefaultPort));
|
||||
return {
|
||||
matched: matched && (hasUsableAddress || hasSnapshot),
|
||||
confidence: matched && candidateArg.id ? 'certain' : matched && hasUsableAddress ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched
|
||||
? hasUsableAddress || hasSnapshot ? 'Candidate has AdGuard Home metadata and a usable HTTP address or snapshot.' : 'Candidate has AdGuard Home metadata but no usable HTTP address.'
|
||||
: 'Candidate is not AdGuard Home.',
|
||||
candidate: matched && (hasUsableAddress || hasSnapshot) ? candidateArg : undefined,
|
||||
normalizedDeviceId: candidateArg.id || candidateArg.host && `${candidateArg.host}:${candidateArg.port || adguardDefaultPort}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createAdguardDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({
|
||||
integrationDomain: 'adguard',
|
||||
displayName: 'AdGuard Home',
|
||||
})
|
||||
.addMatcher(new AdguardManualMatcher())
|
||||
.addValidator(new AdguardCandidateValidator());
|
||||
};
|
||||
|
||||
const isAdguardHint = (valueArg: { integrationDomain?: string; manufacturer?: string; model?: string; name?: string; metadata?: Record<string, unknown> }): boolean => {
|
||||
const manufacturer = valueArg.manufacturer?.toLowerCase() || '';
|
||||
const model = valueArg.model?.toLowerCase() || '';
|
||||
const name = valueArg.name?.toLowerCase() || '';
|
||||
return valueArg.integrationDomain === 'adguard'
|
||||
|| manufacturer.includes('adguard')
|
||||
|| model.includes('adguard')
|
||||
|| name.includes('adguard')
|
||||
|| Boolean(valueArg.metadata?.adguard);
|
||||
};
|
||||
|
||||
const isValidPort = (valueArg: number): boolean => {
|
||||
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
|
||||
};
|
||||
|
||||
const isAdguardSnapshot = (valueArg: unknown): valueArg is IAdguardSnapshot => {
|
||||
return typeof valueArg === 'object' && valueArg !== null && 'status' in valueArg && 'online' in valueArg;
|
||||
};
|
||||
@@ -0,0 +1,401 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity, IServiceCallRequest } from '../../core/types.js';
|
||||
import type {
|
||||
IAdguardClientCommand,
|
||||
IAdguardFilterSubscription,
|
||||
IAdguardQueryLogConfig,
|
||||
IAdguardSafeSearchConfig,
|
||||
IAdguardSnapshot,
|
||||
TAdguardSwitchKey,
|
||||
} from './adguard.types.js';
|
||||
|
||||
const adguardDomain = 'adguard';
|
||||
|
||||
const switchDescriptions: Array<{ key: TAdguardSwitchKey; name: string }> = [
|
||||
{ key: 'protection', name: 'Protection' },
|
||||
{ key: 'parental', name: 'Parental control' },
|
||||
{ key: 'safesearch', name: 'Safe search' },
|
||||
{ key: 'safebrowsing', name: 'Safe browsing' },
|
||||
{ key: 'filtering', name: 'Filtering' },
|
||||
{ key: 'querylog', name: 'Query log' },
|
||||
];
|
||||
|
||||
const sensorDescriptions: Array<{ key: string; name: string; unit?: string; value: (snapshotArg: IAdguardSnapshot) => number | string | null }> = [
|
||||
{ key: 'dns_queries', name: 'DNS queries', unit: 'queries', value: (snapshotArg) => numberOrNull(snapshotArg.stats.num_dns_queries) ?? sum(snapshotArg.stats.dns_queries) },
|
||||
{ key: 'blocked_filtering', name: 'DNS queries blocked', unit: 'queries', value: (snapshotArg) => numberOrNull(snapshotArg.stats.num_blocked_filtering) ?? sum(snapshotArg.stats.blocked_filtering) },
|
||||
{ key: 'blocked_percentage', name: 'DNS queries blocked ratio', unit: '%', value: (snapshotArg) => blockedPercentage(snapshotArg) },
|
||||
{ key: 'blocked_parental', name: 'Parental control blocked', unit: 'requests', value: (snapshotArg) => numberOrNull(snapshotArg.stats.num_replaced_parental) ?? sum(snapshotArg.stats.replaced_parental) },
|
||||
{ key: 'blocked_safebrowsing', name: 'Safe browsing blocked', unit: 'requests', value: (snapshotArg) => numberOrNull(snapshotArg.stats.num_replaced_safebrowsing) ?? sum(snapshotArg.stats.replaced_safebrowsing) },
|
||||
{ key: 'enforced_safesearch', name: 'Safe searches enforced', unit: 'requests', value: (snapshotArg) => numberOrNull(snapshotArg.stats.num_replaced_safesearch) ?? sum(snapshotArg.stats.replaced_safesearch) },
|
||||
{ key: 'average_speed', name: 'Average processing speed', unit: 'ms', value: (snapshotArg) => rounded(numberOrNull(snapshotArg.stats.avg_processing_time) === null ? null : numberOrNull(snapshotArg.stats.avg_processing_time)! * 1000) },
|
||||
{ key: 'rules_count', name: 'Rules count', unit: 'rules', value: (snapshotArg) => rulesCount(snapshotArg.filtering.filters) },
|
||||
];
|
||||
|
||||
export class AdguardMapper {
|
||||
public static toDevices(snapshotArg: IAdguardSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const deviceId = this.deviceId(snapshotArg);
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||
{ id: 'running', capability: 'sensor', name: 'Running', readable: true, writable: false },
|
||||
{ id: 'version', capability: 'sensor', name: 'Version', readable: true, writable: false },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'connectivity', value: snapshotArg.online ? 'online' : 'offline', updatedAt },
|
||||
{ featureId: 'running', value: snapshotArg.status.running ?? snapshotArg.online, updatedAt },
|
||||
{ featureId: 'version', value: snapshotArg.status.version || null, updatedAt },
|
||||
];
|
||||
|
||||
for (const description of switchDescriptions) {
|
||||
features.push({ id: description.key, capability: 'switch', name: description.name, readable: true, writable: true });
|
||||
state.push({ featureId: description.key, value: this.switchState(snapshotArg, description.key), updatedAt });
|
||||
}
|
||||
|
||||
for (const sensor of sensorDescriptions) {
|
||||
features.push({ id: sensor.key, capability: 'sensor', name: sensor.name, readable: true, writable: false, unit: sensor.unit });
|
||||
state.push({ featureId: sensor.key, value: sensor.value(snapshotArg), updatedAt });
|
||||
}
|
||||
|
||||
return [{
|
||||
id: deviceId,
|
||||
integrationDomain: adguardDomain,
|
||||
name: this.deviceName(snapshotArg),
|
||||
protocol: 'http',
|
||||
manufacturer: 'AdGuard Team',
|
||||
model: 'AdGuard Home',
|
||||
online: snapshotArg.online,
|
||||
features,
|
||||
state,
|
||||
metadata: this.cleanAttributes({
|
||||
host: snapshotArg.host,
|
||||
port: snapshotArg.port,
|
||||
basePath: snapshotArg.basePath,
|
||||
version: snapshotArg.status.version,
|
||||
language: snapshotArg.status.language,
|
||||
dnsPort: snapshotArg.status.dns_port,
|
||||
httpPort: snapshotArg.status.http_port,
|
||||
source: snapshotArg.source,
|
||||
error: snapshotArg.error,
|
||||
}),
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IAdguardSnapshot): IIntegrationEntity[] {
|
||||
const deviceId = this.deviceId(snapshotArg);
|
||||
const baseName = this.deviceName(snapshotArg);
|
||||
const baseSlug = this.slug(baseName);
|
||||
const uniqueBase = this.uniqueBase(snapshotArg);
|
||||
const entities: IIntegrationEntity[] = [
|
||||
this.entity('binary_sensor', `${baseName} Running`, deviceId, `${uniqueBase}_running`, snapshotArg.status.running ?? snapshotArg.online ? 'on' : 'off', snapshotArg.online, {
|
||||
deviceClass: 'running',
|
||||
host: snapshotArg.host,
|
||||
port: snapshotArg.port,
|
||||
}),
|
||||
];
|
||||
|
||||
if (snapshotArg.status.version) {
|
||||
entities.push(this.entity('sensor', `${baseName} Version`, deviceId, `${uniqueBase}_version`, snapshotArg.status.version, snapshotArg.online, {
|
||||
entityCategory: 'diagnostic',
|
||||
}));
|
||||
}
|
||||
|
||||
for (const description of switchDescriptions) {
|
||||
const state = this.switchState(snapshotArg, description.key);
|
||||
entities.push({
|
||||
id: `switch.${baseSlug}_${this.slug(description.name)}`,
|
||||
uniqueId: `adguard_${uniqueBase}_switch_${description.key}`,
|
||||
integrationDomain: adguardDomain,
|
||||
deviceId,
|
||||
platform: 'switch',
|
||||
name: `${baseName} ${description.name}`,
|
||||
state: state ? 'on' : 'off',
|
||||
attributes: this.cleanAttributes({
|
||||
adguardSwitchKey: description.key,
|
||||
writable: true,
|
||||
}),
|
||||
available: snapshotArg.online,
|
||||
});
|
||||
}
|
||||
|
||||
for (const sensor of sensorDescriptions) {
|
||||
const value = sensor.value(snapshotArg);
|
||||
entities.push(this.entity('sensor', `${baseName} ${sensor.name}`, deviceId, `${uniqueBase}_sensor_${sensor.key}`, value, snapshotArg.online && value !== null, {
|
||||
unit: sensor.unit,
|
||||
stateClass: typeof value === 'number' ? 'measurement' : undefined,
|
||||
entityRegistryEnabledDefault: sensor.key === 'rules_count' ? false : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
if (snapshotArg.update && !snapshotArg.update.disabled) {
|
||||
entities.push(this.entity('update', baseName, deviceId, `${uniqueBase}_update`, snapshotArg.update.new_version && snapshotArg.update.new_version !== snapshotArg.status.version ? 'on' : 'off', snapshotArg.online, {
|
||||
installedVersion: snapshotArg.status.version,
|
||||
latestVersion: snapshotArg.update.new_version,
|
||||
releaseSummary: snapshotArg.update.announcement,
|
||||
releaseUrl: snapshotArg.update.announcement_url,
|
||||
canAutoupdate: snapshotArg.update.can_autoupdate,
|
||||
}));
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static commandForService(snapshotArg: IAdguardSnapshot, requestArg: IServiceCallRequest): IAdguardClientCommand | { error: string } | undefined {
|
||||
if (requestArg.domain === adguardDomain) {
|
||||
return this.adguardServiceCommand(snapshotArg, requestArg);
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'switch' && ['turn_on', 'turn_off'].includes(requestArg.service)) {
|
||||
const target = this.targetSwitch(snapshotArg, requestArg);
|
||||
if ('error' in target) {
|
||||
return target;
|
||||
}
|
||||
return this.switchCommand(snapshotArg, requestArg.service, target.key, requestArg, target.entity);
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'update' && requestArg.service === 'install') {
|
||||
const entity = this.findTargetEntity(snapshotArg, requestArg);
|
||||
if (!entity || entity.platform !== 'update') {
|
||||
return { error: 'AdGuard update.install requires the AdGuard update entity target.' };
|
||||
}
|
||||
return {
|
||||
type: 'begin_update',
|
||||
service: 'install',
|
||||
method: 'POST',
|
||||
path: '/update',
|
||||
target: requestArg.target,
|
||||
entityId: entity.id,
|
||||
deviceId: entity.deviceId,
|
||||
uniqueId: entity.uniqueId,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IAdguardSnapshot): string {
|
||||
return `adguard.service.${this.uniqueBase(snapshotArg)}`;
|
||||
}
|
||||
|
||||
public static switchState(snapshotArg: IAdguardSnapshot, keyArg: TAdguardSwitchKey): boolean {
|
||||
if (keyArg === 'protection') return Boolean(snapshotArg.status.protection_enabled);
|
||||
if (keyArg === 'filtering') return Boolean(snapshotArg.filtering.enabled);
|
||||
if (keyArg === 'querylog') return Boolean(snapshotArg.queryLog.enabled);
|
||||
if (keyArg === 'safebrowsing') return this.booleanStatus(snapshotArg.safebrowsing);
|
||||
if (keyArg === 'safesearch') return Boolean(snapshotArg.safesearch.enabled);
|
||||
return this.booleanStatus(snapshotArg.parental);
|
||||
}
|
||||
|
||||
public static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'adguard';
|
||||
}
|
||||
|
||||
private static adguardServiceCommand(snapshotArg: IAdguardSnapshot, requestArg: IServiceCallRequest): IAdguardClientCommand | { error: string } | undefined {
|
||||
if (requestArg.service === 'add_url') {
|
||||
const name = stringValue(requestArg.data?.name);
|
||||
const url = stringValue(requestArg.data?.url);
|
||||
if (!name || !url || !isUrlOrAbsolutePath(url)) {
|
||||
return { error: 'AdGuard add_url requires data.name and data.url as a URL or absolute path.' };
|
||||
}
|
||||
return this.command('add_filter_url', 'add_url', 'POST', '/filtering/add_url', requestArg, { name, url, whitelist: false }, { name, url });
|
||||
}
|
||||
|
||||
if (requestArg.service === 'remove_url') {
|
||||
const url = stringValue(requestArg.data?.url);
|
||||
if (!url || !isUrlOrAbsolutePath(url)) {
|
||||
return { error: 'AdGuard remove_url requires data.url as a URL or absolute path.' };
|
||||
}
|
||||
return this.command('remove_filter_url', 'remove_url', 'POST', '/filtering/remove_url', requestArg, { url, whitelist: false }, { url });
|
||||
}
|
||||
|
||||
if (requestArg.service === 'enable_url' || requestArg.service === 'disable_url') {
|
||||
const url = stringValue(requestArg.data?.url);
|
||||
if (!url || !isUrlOrAbsolutePath(url)) {
|
||||
return { error: 'AdGuard enable_url/disable_url requires data.url as a URL or absolute path.' };
|
||||
}
|
||||
const filter = this.filterByUrl(snapshotArg, url);
|
||||
if (!filter) {
|
||||
return { error: `AdGuard filter URL is not present in the current filtering snapshot: ${url}` };
|
||||
}
|
||||
const enabled = requestArg.service === 'enable_url';
|
||||
return this.command('set_filter_url_enabled', requestArg.service, 'POST', '/filtering/set_url', requestArg, {
|
||||
url,
|
||||
whitelist: false,
|
||||
data: {
|
||||
name: filter.name || url,
|
||||
url,
|
||||
enabled,
|
||||
},
|
||||
}, { url, name: filter.name || url, enabled });
|
||||
}
|
||||
|
||||
if (requestArg.service === 'refresh') {
|
||||
const force = booleanValue(requestArg.data?.force) ?? false;
|
||||
return this.command('refresh_filters', 'refresh', 'POST', '/filtering/refresh', requestArg, { whitelist: false, force }, { force });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static switchCommand(snapshotArg: IAdguardSnapshot, serviceArg: string, keyArg: TAdguardSwitchKey, requestArg: IServiceCallRequest, entityArg: IIntegrationEntity | undefined): IAdguardClientCommand {
|
||||
const enabled = serviceArg === 'turn_on';
|
||||
if (keyArg === 'protection') {
|
||||
return this.command('set_protection', serviceArg, 'POST', '/protection', requestArg, { enabled }, { enabled, switchKey: keyArg, entity: entityArg });
|
||||
}
|
||||
if (keyArg === 'filtering') {
|
||||
return this.command('set_filtering', serviceArg, 'POST', '/filtering/config', requestArg, { enabled }, { enabled, switchKey: keyArg, entity: entityArg });
|
||||
}
|
||||
if (keyArg === 'querylog') {
|
||||
return this.command('set_querylog', serviceArg, 'PUT', '/querylog/config/update', requestArg, this.queryLogPayload(snapshotArg.queryLog, enabled), { enabled, switchKey: keyArg, entity: entityArg });
|
||||
}
|
||||
if (keyArg === 'safebrowsing') {
|
||||
return this.command('set_safebrowsing', serviceArg, 'POST', `/safebrowsing/${enabled ? 'enable' : 'disable'}`, requestArg, undefined, { enabled, switchKey: keyArg, entity: entityArg });
|
||||
}
|
||||
if (keyArg === 'safesearch') {
|
||||
return this.command('set_safesearch', serviceArg, 'PUT', '/safesearch/settings', requestArg, this.safeSearchPayload(snapshotArg.safesearch, enabled), { enabled, switchKey: keyArg, entity: entityArg });
|
||||
}
|
||||
return this.command('set_parental', serviceArg, 'POST', `/parental/${enabled ? 'enable' : 'disable'}`, requestArg, undefined, { enabled, switchKey: keyArg, entity: entityArg });
|
||||
}
|
||||
|
||||
private static targetSwitch(snapshotArg: IAdguardSnapshot, requestArg: IServiceCallRequest): { key: TAdguardSwitchKey; entity?: IIntegrationEntity } | { error: string } {
|
||||
const entity = this.findTargetEntity(snapshotArg, requestArg);
|
||||
const key = entity?.attributes?.adguardSwitchKey;
|
||||
if (isSwitchKey(key)) {
|
||||
return { key, entity };
|
||||
}
|
||||
if (requestArg.target.deviceId && requestArg.target.deviceId === this.deviceId(snapshotArg)) {
|
||||
return { error: 'AdGuard switch service calls require a switch entity target because the AdGuard device has multiple switches.' };
|
||||
}
|
||||
return { error: 'AdGuard switch service calls require an AdGuard switch entity target.' };
|
||||
}
|
||||
|
||||
private static command(typeArg: IAdguardClientCommand['type'], serviceArg: string, methodArg: IAdguardClientCommand['method'], pathArg: string, requestArg: IServiceCallRequest, payloadArg?: Record<string, unknown>, optionsArg: { entity?: IIntegrationEntity; enabled?: boolean; switchKey?: TAdguardSwitchKey; url?: string; name?: string; force?: boolean } = {}): IAdguardClientCommand {
|
||||
return this.cleanAttributes({
|
||||
type: typeArg,
|
||||
service: serviceArg,
|
||||
method: methodArg,
|
||||
path: pathArg,
|
||||
payload: payloadArg,
|
||||
target: requestArg.target,
|
||||
entityId: optionsArg.entity?.id || requestArg.target.entityId,
|
||||
deviceId: optionsArg.entity?.deviceId || requestArg.target.deviceId,
|
||||
uniqueId: optionsArg.entity?.uniqueId,
|
||||
switchKey: optionsArg.switchKey,
|
||||
enabled: optionsArg.enabled,
|
||||
url: optionsArg.url,
|
||||
name: optionsArg.name,
|
||||
force: optionsArg.force,
|
||||
}) as IAdguardClientCommand;
|
||||
}
|
||||
|
||||
private static findTargetEntity(snapshotArg: IAdguardSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
|
||||
if (!requestArg.target.entityId) {
|
||||
return undefined;
|
||||
}
|
||||
return this.toEntities(snapshotArg).find((entityArg) => entityArg.id === requestArg.target.entityId);
|
||||
}
|
||||
|
||||
private static filterByUrl(snapshotArg: IAdguardSnapshot, urlArg: string): IAdguardFilterSubscription | undefined {
|
||||
return (snapshotArg.filtering.filters || []).find((filterArg) => filterArg.url === urlArg);
|
||||
}
|
||||
|
||||
private static queryLogPayload(configArg: IAdguardQueryLogConfig, enabledArg: boolean): Record<string, unknown> {
|
||||
return {
|
||||
enabled: enabledArg,
|
||||
interval: configArg.interval ?? 7776000000,
|
||||
anonymize_client_ip: configArg.anonymize_client_ip ?? false,
|
||||
ignored: configArg.ignored || [],
|
||||
ignored_enabled: configArg.ignored_enabled ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
private static safeSearchPayload(configArg: IAdguardSafeSearchConfig, enabledArg: boolean): Record<string, unknown> {
|
||||
return {
|
||||
...configArg,
|
||||
enabled: enabledArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static entity(platformArg: IIntegrationEntity['platform'], nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, availableArg: boolean, attributesArg: Record<string, unknown> = {}): IIntegrationEntity {
|
||||
return {
|
||||
id: `${platformArg}.${this.slug(nameArg)}`,
|
||||
uniqueId: `adguard_${uniqueIdArg}`,
|
||||
integrationDomain: adguardDomain,
|
||||
deviceId: deviceIdArg,
|
||||
platform: platformArg,
|
||||
name: nameArg,
|
||||
state: stateArg,
|
||||
attributes: this.cleanAttributes(attributesArg),
|
||||
available: availableArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static deviceName(snapshotArg: IAdguardSnapshot): string {
|
||||
return snapshotArg.name || 'AdGuard Home';
|
||||
}
|
||||
|
||||
private static uniqueBase(snapshotArg: IAdguardSnapshot): string {
|
||||
return this.slug(snapshotArg.uniqueId || snapshotArg.host && `${snapshotArg.host}_${snapshotArg.port || ''}` || this.deviceName(snapshotArg));
|
||||
}
|
||||
|
||||
private static booleanStatus(valueArg: { enabled?: boolean; enable?: boolean }): boolean {
|
||||
return Boolean(valueArg.enabled ?? valueArg.enable);
|
||||
}
|
||||
|
||||
private static cleanAttributes<T extends Record<string, unknown>>(attributesArg: T): T {
|
||||
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)) as T;
|
||||
}
|
||||
}
|
||||
|
||||
const numberOrNull = (valueArg: unknown): number | null => {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : null;
|
||||
};
|
||||
|
||||
const sum = (valuesArg: number[] | undefined): number => {
|
||||
return (valuesArg || []).reduce((totalArg, valueArg) => totalArg + (Number.isFinite(valueArg) ? valueArg : 0), 0);
|
||||
};
|
||||
|
||||
const rounded = (valueArg: number | null): number | null => {
|
||||
return valueArg === null ? null : Math.round(valueArg * 100) / 100;
|
||||
};
|
||||
|
||||
const rulesCount = (filtersArg: IAdguardFilterSubscription[] | undefined): number => {
|
||||
return (filtersArg || []).reduce((totalArg, filterArg) => totalArg + (typeof filterArg.rules_count === 'number' ? filterArg.rules_count : 0), 0);
|
||||
};
|
||||
|
||||
const blockedPercentage = (snapshotArg: IAdguardSnapshot): number => {
|
||||
const queries = numberOrNull(snapshotArg.stats.num_dns_queries) ?? sum(snapshotArg.stats.dns_queries);
|
||||
if (!queries) {
|
||||
return 0;
|
||||
}
|
||||
const blocked = numberOrNull(snapshotArg.stats.num_blocked_filtering) ?? sum(snapshotArg.stats.blocked_filtering);
|
||||
return rounded(blocked / queries * 100) || 0;
|
||||
};
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
};
|
||||
|
||||
const booleanValue = (valueArg: unknown): boolean | undefined => {
|
||||
return typeof valueArg === 'boolean' ? valueArg : undefined;
|
||||
};
|
||||
|
||||
const isUrlOrAbsolutePath = (valueArg: string): boolean => {
|
||||
if (valueArg.startsWith('/')) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(valueArg);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isSwitchKey = (valueArg: unknown): valueArg is TAdguardSwitchKey => {
|
||||
return valueArg === 'protection'
|
||||
|| valueArg === 'filtering'
|
||||
|| valueArg === 'querylog'
|
||||
|| valueArg === 'safebrowsing'
|
||||
|| valueArg === 'safesearch'
|
||||
|| valueArg === 'parental';
|
||||
};
|
||||
@@ -1,4 +1,198 @@
|
||||
export interface IHomeAssistantAdguardConfig {
|
||||
// TODO: replace with the TypeScript-native config for adguard.
|
||||
import type { IServiceCallResult } from '../../core/types.js';
|
||||
|
||||
export const adguardDefaultPort = 3000;
|
||||
export const adguardDefaultTimeoutMs = 10000;
|
||||
|
||||
export type TAdguardJsonValue = string | number | boolean | null | TAdguardJsonValue[] | {
|
||||
[key: string]: TAdguardJsonValue | undefined;
|
||||
};
|
||||
|
||||
export type TAdguardSnapshotSource = 'snapshot' | 'http' | 'manual' | 'runtime';
|
||||
|
||||
export type TAdguardHttpMethod = 'GET' | 'POST' | 'PUT';
|
||||
|
||||
export type TAdguardSwitchKey = 'protection' | 'filtering' | 'querylog' | 'safebrowsing' | 'safesearch' | 'parental';
|
||||
|
||||
export type TAdguardCommandType =
|
||||
| 'set_protection'
|
||||
| 'set_filtering'
|
||||
| 'set_querylog'
|
||||
| 'set_safebrowsing'
|
||||
| 'set_safesearch'
|
||||
| 'set_parental'
|
||||
| 'add_filter_url'
|
||||
| 'remove_filter_url'
|
||||
| 'set_filter_url_enabled'
|
||||
| 'refresh_filters'
|
||||
| 'begin_update';
|
||||
|
||||
export interface IAdguardServerStatus {
|
||||
dns_addresses?: string[];
|
||||
dns_port?: number;
|
||||
http_port?: number;
|
||||
protection_enabled?: boolean;
|
||||
protection_disabled_until?: string | null;
|
||||
protection_disabled_duration?: number;
|
||||
dhcp_available?: boolean;
|
||||
running?: boolean;
|
||||
version?: string;
|
||||
language?: string;
|
||||
start_time?: number;
|
||||
[key: string]: TAdguardJsonValue | undefined;
|
||||
}
|
||||
|
||||
export interface IAdguardFilterSubscription {
|
||||
enabled?: boolean;
|
||||
id?: number;
|
||||
name?: string;
|
||||
rules_count?: number;
|
||||
url?: string;
|
||||
last_updated?: string;
|
||||
[key: string]: TAdguardJsonValue | undefined;
|
||||
}
|
||||
|
||||
export interface IAdguardFilteringStatus {
|
||||
enabled?: boolean;
|
||||
interval?: number;
|
||||
filters?: IAdguardFilterSubscription[];
|
||||
whitelist_filters?: IAdguardFilterSubscription[];
|
||||
user_rules?: string[];
|
||||
[key: string]: TAdguardJsonValue | IAdguardFilterSubscription[] | string[] | undefined;
|
||||
}
|
||||
|
||||
export interface IAdguardQueryLogConfig {
|
||||
enabled?: boolean;
|
||||
interval?: number;
|
||||
anonymize_client_ip?: boolean;
|
||||
ignored?: string[];
|
||||
ignored_enabled?: boolean;
|
||||
[key: string]: TAdguardJsonValue | string[] | undefined;
|
||||
}
|
||||
|
||||
export interface IAdguardBooleanStatus {
|
||||
enabled?: boolean;
|
||||
enable?: boolean;
|
||||
sensitivity?: number;
|
||||
[key: string]: TAdguardJsonValue | undefined;
|
||||
}
|
||||
|
||||
export interface IAdguardSafeSearchConfig {
|
||||
enabled?: boolean;
|
||||
bing?: boolean;
|
||||
duckduckgo?: boolean;
|
||||
ecosia?: boolean;
|
||||
google?: boolean;
|
||||
pixabay?: boolean;
|
||||
yandex?: boolean;
|
||||
youtube?: boolean;
|
||||
[key: string]: TAdguardJsonValue | undefined;
|
||||
}
|
||||
|
||||
export interface IAdguardStats {
|
||||
time_units?: 'hours' | 'days' | string;
|
||||
num_dns_queries?: number;
|
||||
num_blocked_filtering?: number;
|
||||
num_replaced_safebrowsing?: number;
|
||||
num_replaced_safesearch?: number;
|
||||
num_replaced_parental?: number;
|
||||
avg_processing_time?: number;
|
||||
dns_queries?: number[];
|
||||
blocked_filtering?: number[];
|
||||
replaced_safebrowsing?: number[];
|
||||
replaced_parental?: number[];
|
||||
replaced_safesearch?: number[];
|
||||
[key: string]: TAdguardJsonValue | number[] | undefined;
|
||||
}
|
||||
|
||||
export interface IAdguardVersionInfo {
|
||||
disabled?: boolean;
|
||||
new_version?: string;
|
||||
announcement?: string;
|
||||
announcement_url?: string;
|
||||
can_autoupdate?: boolean;
|
||||
[key: string]: TAdguardJsonValue | undefined;
|
||||
}
|
||||
|
||||
export interface IAdguardSnapshot {
|
||||
online: boolean;
|
||||
status: IAdguardServerStatus;
|
||||
filtering: IAdguardFilteringStatus;
|
||||
queryLog: IAdguardQueryLogConfig;
|
||||
safebrowsing: IAdguardBooleanStatus;
|
||||
safesearch: IAdguardSafeSearchConfig;
|
||||
parental: IAdguardBooleanStatus;
|
||||
stats: IAdguardStats;
|
||||
update?: IAdguardVersionInfo;
|
||||
host?: string;
|
||||
port?: number;
|
||||
ssl?: boolean;
|
||||
basePath?: string;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
updatedAt?: string;
|
||||
source?: TAdguardSnapshotSource;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IAdguardClientCommand {
|
||||
type: TAdguardCommandType;
|
||||
service: string;
|
||||
method: TAdguardHttpMethod;
|
||||
path: string;
|
||||
payload?: Record<string, unknown>;
|
||||
target?: {
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
uniqueId?: string;
|
||||
switchKey?: TAdguardSwitchKey;
|
||||
enabled?: boolean;
|
||||
url?: string;
|
||||
name?: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface IAdguardCommandResult extends IServiceCallResult {}
|
||||
|
||||
export type TAdguardCommandExecutor = (
|
||||
commandArg: IAdguardClientCommand
|
||||
) => Promise<IAdguardCommandResult | unknown> | IAdguardCommandResult | unknown;
|
||||
|
||||
export interface IAdguardConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
basePath?: string;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
timeoutMs?: number;
|
||||
snapshot?: IAdguardSnapshot;
|
||||
commandExecutor?: TAdguardCommandExecutor;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IAdguardManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
basePath?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
manufacturer?: string;
|
||||
snapshot?: IAdguardSnapshot;
|
||||
metadata?: Record<string, unknown>;
|
||||
integrationDomain?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantAdguardConfig extends IAdguardConfig {}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './adguard.classes.integration.js';
|
||||
export * from './adguard.classes.client.js';
|
||||
export * from './adguard.classes.configflow.js';
|
||||
export * from './adguard.discovery.js';
|
||||
export * from './adguard.mapper.js';
|
||||
export * from './adguard.types.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,950 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
IAmcrestBinarySensor,
|
||||
IAmcrestCamera,
|
||||
IAmcrestClientCommand,
|
||||
IAmcrestCommandResponse,
|
||||
IAmcrestConfig,
|
||||
IAmcrestDeviceInfo,
|
||||
IAmcrestEvent,
|
||||
IAmcrestHttpCommand,
|
||||
IAmcrestSensor,
|
||||
IAmcrestSnapshot,
|
||||
IAmcrestSnapshotImage,
|
||||
IAmcrestSwitch,
|
||||
TAmcrestAuthScheme,
|
||||
TAmcrestColorBw,
|
||||
TAmcrestProtocol,
|
||||
TAmcrestPtzMovement,
|
||||
TAmcrestResolution,
|
||||
TAmcrestStreamSource,
|
||||
} from './amcrest.types.js';
|
||||
import {
|
||||
amcrestBinarySensorDescriptions,
|
||||
amcrestColorModes,
|
||||
amcrestDefaultPort,
|
||||
amcrestDefaultRtspPort,
|
||||
amcrestDefaultSnapshotTimeoutMs,
|
||||
amcrestDefaultTimeoutMs,
|
||||
amcrestResolutionSubtype,
|
||||
amcrestSensorDescriptions,
|
||||
amcrestSubtypeStream,
|
||||
amcrestSwitchDescriptions,
|
||||
} from './amcrest.types.js';
|
||||
|
||||
const ptzCodes: Record<TAmcrestPtzMovement, string> = {
|
||||
zoom_out: 'ZoomWide',
|
||||
zoom_in: 'ZoomTele',
|
||||
right: 'Right',
|
||||
left: 'Left',
|
||||
up: 'Up',
|
||||
down: 'Down',
|
||||
right_down: 'RightDown',
|
||||
right_up: 'RightUp',
|
||||
left_down: 'LeftDown',
|
||||
left_up: 'LeftUp',
|
||||
};
|
||||
|
||||
const ptzMoveOneArg2 = new Set(['Right', 'Left', 'Up', 'Down']);
|
||||
const ptzMoveBothArgs = new Set(['RightDown', 'RightUp', 'LeftDown', 'LeftUp']);
|
||||
|
||||
export class AmcrestHttpError extends Error {
|
||||
constructor(public readonly status: number, messageArg: string) {
|
||||
super(messageArg);
|
||||
this.name = 'AmcrestHttpError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AmcrestClient {
|
||||
private snapshot?: IAmcrestSnapshot;
|
||||
|
||||
constructor(private readonly config: IAmcrestConfig) {}
|
||||
|
||||
public async getSnapshot(forceRefreshArg = false): Promise<IAmcrestSnapshot> {
|
||||
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.fetchLiveSnapshot();
|
||||
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.requestText('/cgi-bin/magicBox.cgi?action=getDeviceType');
|
||||
}
|
||||
|
||||
public async execute(commandArg: IAmcrestClientCommand): Promise<unknown> {
|
||||
if (commandArg.type === 'refresh') {
|
||||
return this.getSnapshot(true);
|
||||
}
|
||||
if (commandArg.type === 'stream_source') {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const camera = this.findCamera(snapshot, commandArg.cameraId);
|
||||
return {
|
||||
cameraId: camera.id,
|
||||
channel: camera.channel,
|
||||
resolution: commandArg.resolution || camera.resolution,
|
||||
streamSource: commandArg.streamSource || camera.streamSource,
|
||||
streamSourceUrl: this.streamSourceUrl(camera, commandArg.streamSource),
|
||||
snapshotUrl: camera.snapshotUrl,
|
||||
mjpegUrl: camera.mjpegUrl,
|
||||
rtspUrl: camera.rtspUrl,
|
||||
verified: false,
|
||||
};
|
||||
}
|
||||
if (commandArg.type === 'snapshot_image') {
|
||||
if (commandArg.filename) {
|
||||
throw new Error('Amcrest snapshot file writes are not implemented; request data as base64 without data.filename.');
|
||||
}
|
||||
const image = await this.getSnapshotImage(commandArg.channel);
|
||||
return {
|
||||
contentType: image.contentType,
|
||||
dataBase64: Buffer.from(image.data).toString('base64'),
|
||||
};
|
||||
}
|
||||
if (commandArg.type === 'set_privacy_mode') {
|
||||
const enabled = this.requireEnabled(commandArg);
|
||||
const response = await this.setBooleanConfig('privacy_mode', enabled, commandArg.channel);
|
||||
this.patchCachedSwitch('privacy_mode', enabled);
|
||||
return { ok: true, command: commandArg.type, responses: [response] };
|
||||
}
|
||||
if (commandArg.type === 'set_video') {
|
||||
const enabled = this.requireEnabled(commandArg);
|
||||
if (!enabled && this.snapshot?.cameras.some((cameraArg) => cameraArg.isRecording)) {
|
||||
await this.setRecordMode(false, commandArg.channel);
|
||||
}
|
||||
const response = await this.setBooleanConfig('video', enabled, commandArg.channel);
|
||||
this.patchCachedCamera({ isStreaming: enabled });
|
||||
return { ok: true, command: commandArg.type, responses: [response] };
|
||||
}
|
||||
if (commandArg.type === 'set_recording') {
|
||||
const enabled = this.requireEnabled(commandArg);
|
||||
if (enabled && this.snapshot?.cameras.some((cameraArg) => cameraArg.isStreaming === false)) {
|
||||
await this.setBooleanConfig('video', true, commandArg.channel);
|
||||
}
|
||||
const response = await this.setRecordMode(enabled, commandArg.channel);
|
||||
this.patchCachedCamera({ isRecording: enabled });
|
||||
return { ok: true, command: commandArg.type, responses: [response] };
|
||||
}
|
||||
if (commandArg.type === 'set_audio') {
|
||||
const enabled = this.requireEnabled(commandArg);
|
||||
const response = await this.setBooleanConfig('audio', enabled, commandArg.channel);
|
||||
this.patchCachedCamera({ audioEnabled: enabled });
|
||||
return { ok: true, command: commandArg.type, responses: [response] };
|
||||
}
|
||||
if (commandArg.type === 'set_motion_detection') {
|
||||
const enabled = this.requireEnabled(commandArg);
|
||||
const response = await this.setBooleanConfig('motion_detection', enabled, commandArg.channel);
|
||||
this.patchCachedCamera({ motionDetectionEnabled: enabled });
|
||||
return { ok: true, command: commandArg.type, responses: [response] };
|
||||
}
|
||||
if (commandArg.type === 'set_motion_recording') {
|
||||
const enabled = this.requireEnabled(commandArg);
|
||||
const response = await this.setBooleanConfig('motion_recording', enabled, commandArg.channel);
|
||||
this.patchCachedCamera({ motionRecordingEnabled: enabled });
|
||||
return { ok: true, command: commandArg.type, responses: [response] };
|
||||
}
|
||||
if (commandArg.type === 'set_color_bw') {
|
||||
if (!commandArg.colorBw) {
|
||||
throw new Error('Amcrest set_color_bw requires a supported color_bw value.');
|
||||
}
|
||||
const response = await this.setColorBw(commandArg.colorBw, commandArg.channel);
|
||||
this.patchCachedCamera({ colorBw: commandArg.colorBw });
|
||||
return { ok: true, command: commandArg.type, responses: [response] };
|
||||
}
|
||||
if (commandArg.type === 'goto_preset') {
|
||||
if (typeof commandArg.preset !== 'number' || !Number.isFinite(commandArg.preset) || commandArg.preset < 1) {
|
||||
throw new Error('Amcrest goto_preset requires a positive preset number.');
|
||||
}
|
||||
const response = await this.gotoPreset(commandArg.preset, commandArg.channel);
|
||||
return { ok: true, command: commandArg.type, responses: [response] };
|
||||
}
|
||||
if (commandArg.type === 'ptz_control') {
|
||||
if (!commandArg.movement) {
|
||||
throw new Error('Amcrest ptz_control requires a movement value.');
|
||||
}
|
||||
const responses = await this.ptzControl(commandArg.movement, commandArg.travelTime, commandArg.channel);
|
||||
return { ok: true, command: commandArg.type, responses };
|
||||
}
|
||||
if (commandArg.type === 'start_tour' || commandArg.type === 'stop_tour') {
|
||||
const response = await this.tour(commandArg.type === 'start_tour', commandArg.channel);
|
||||
return { ok: true, command: commandArg.type, responses: [response] };
|
||||
}
|
||||
throw new Error(`Unsupported Amcrest command: ${commandArg.type}`);
|
||||
}
|
||||
|
||||
public async getSnapshotImage(channelArg?: number): Promise<IAmcrestSnapshotImage> {
|
||||
const response = await this.request(this.snapshotHttpCommand(channelArg).path, {}, this.config.snapshotTimeoutMs || amcrestDefaultSnapshotTimeoutMs);
|
||||
return {
|
||||
contentType: response.headers.get('content-type') || 'image/jpeg',
|
||||
data: new Uint8Array(await response.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async fetchLiveSnapshot(): Promise<IAmcrestSnapshot> {
|
||||
const channel = this.channel();
|
||||
const subtype = this.subtype();
|
||||
const streamFormat = this.streamFormat(subtype);
|
||||
const deviceTypeText = await this.requestText('/cgi-bin/magicBox.cgi?action=getDeviceType');
|
||||
const [vendorText, serialText, encodeText, recordText, motionText, privacyText, motionEventText, audioMutationEventText, audioIntensityEventText, crosslineEventText, presetText, storageText, colorText] = await Promise.all([
|
||||
this.requestText('/cgi-bin/magicBox.cgi?action=getVendor').catch(() => undefined),
|
||||
this.requestText('/cgi-bin/magicBox.cgi?action=getSerialNo').catch(() => undefined),
|
||||
this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=Encode').catch(() => undefined),
|
||||
this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=RecordMode').catch(() => undefined),
|
||||
this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=MotionDetect').catch(() => undefined),
|
||||
this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=LeLensMask').catch(() => undefined),
|
||||
this.requestText('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=VideoMotion').catch(() => undefined),
|
||||
this.requestText('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=AudioMutation').catch(() => undefined),
|
||||
this.requestText('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=AudioIntensity').catch(() => undefined),
|
||||
this.requestText('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=CrossLineDetection').catch(() => undefined),
|
||||
this.requestText(`/cgi-bin/ptz.cgi?action=getPresets&channel=${channel}`).catch(() => undefined),
|
||||
this.requestText('/cgi-bin/storage.cgi?action=getDeviceAllInfo').catch(() => undefined),
|
||||
this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=VideoInMode').catch(() => undefined),
|
||||
]);
|
||||
const encodeValues = parseKeyValues(encodeText);
|
||||
const recordValues = parseKeyValues(recordText);
|
||||
const motionValues = parseKeyValues(motionText);
|
||||
const privacyValues = parseKeyValues(privacyText);
|
||||
const colorValues = parseKeyValues(colorText);
|
||||
const model = firstPlainValue(deviceTypeText) || this.config.model;
|
||||
const serialNumber = firstPlainValue(serialText) || this.config.uniqueId;
|
||||
const manufacturer = firstPlainValue(vendorText) || this.config.manufacturer || 'Amcrest';
|
||||
const connected = true;
|
||||
const deviceInfo = this.deviceInfo(connected, { manufacturer, model, serialNumber });
|
||||
const recordMode = valueBySuffix(recordValues, [`RecordMode[${channel}].Mode`, 'RecordMode.Mode', 'Mode']);
|
||||
const storage = this.storageFromText(storageText);
|
||||
const camera = this.camera(deviceInfo, connected, {
|
||||
isStreaming: booleanValue(valueBySuffix(encodeValues, [`Encode[${channel}].${streamFormat}[0].VideoEnable`, `${streamFormat}[0].VideoEnable`, 'VideoEnable'])) ?? true,
|
||||
isRecording: recordMode === 'Manual' || recordMode === '1',
|
||||
motionDetectionEnabled: booleanValue(valueBySuffix(motionValues, [`MotionDetect[${channel}].Enable`, 'MotionDetect.Enable', 'Enable'])),
|
||||
audioEnabled: booleanValue(valueBySuffix(encodeValues, [`Encode[${channel}].${streamFormat}[0].AudioEnable`, `${streamFormat}[0].AudioEnable`, 'AudioEnable'])),
|
||||
motionRecordingEnabled: booleanValue(valueBySuffix(motionValues, [`MotionDetect[${channel}].EventHandler.RecordEnable`, 'EventHandler.RecordEnable', 'RecordEnable'])),
|
||||
colorBw: this.colorModeFromValue(valueBySuffix(colorValues, [`VideoInMode[${channel}].Config[0]`, 'VideoInMode.Config[0]', 'Config[0]'])),
|
||||
});
|
||||
const currentSettings = {
|
||||
...this.config.currentSettings,
|
||||
privacy_mode: booleanValue(valueBySuffix(privacyValues, [`LeLensMask[${channel}].Enable`, 'LeLensMask.Enable', 'Enable'])) ?? false,
|
||||
};
|
||||
const eventStates = {
|
||||
VideoMotion: this.eventTextIsOn(motionEventText),
|
||||
AudioMutation: this.eventTextIsOn(audioMutationEventText),
|
||||
AudioIntensity: this.eventTextIsOn(audioIntensityEventText),
|
||||
CrossLineDetection: this.eventTextIsOn(crosslineEventText),
|
||||
};
|
||||
const sensors = this.config.sensors || this.sensorsFromLive(this.presetCount(presetText), storage, connected);
|
||||
const binarySensors = this.config.binarySensors || this.binarySensorsFromEvents(eventStates, connected);
|
||||
const switches = this.config.switches || this.switchesFromSettings(currentSettings, connected);
|
||||
|
||||
return this.normalizeSnapshot({
|
||||
deviceInfo,
|
||||
cameras: this.config.cameras || [camera],
|
||||
sensors,
|
||||
binarySensors,
|
||||
switches,
|
||||
events: this.config.events || this.eventsFromEventStates(eventStates),
|
||||
currentSettings,
|
||||
connected,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private snapshotFromConfig(connectedArg: boolean, lastErrorArg?: string): IAmcrestSnapshot {
|
||||
const deviceInfo = this.deviceInfo(connectedArg);
|
||||
const currentSettings = this.config.currentSettings || this.config.snapshot?.currentSettings || {};
|
||||
return this.normalizeSnapshot({
|
||||
deviceInfo,
|
||||
cameras: this.config.cameras || this.config.snapshot?.cameras || [this.camera(deviceInfo, connectedArg)],
|
||||
sensors: this.config.sensors || this.config.snapshot?.sensors || this.sensorsFromConfig(connectedArg),
|
||||
binarySensors: this.config.binarySensors || this.config.snapshot?.binarySensors || this.binarySensorsFromEvents({}, connectedArg),
|
||||
switches: this.config.switches || this.config.snapshot?.switches || this.switchesFromSettings(currentSettings, connectedArg),
|
||||
events: this.config.events || this.config.snapshot?.events || [],
|
||||
currentSettings,
|
||||
connected: connectedArg,
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: {
|
||||
...this.config.snapshot?.metadata,
|
||||
lastLiveError: lastErrorArg,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IAmcrestSnapshot): IAmcrestSnapshot {
|
||||
const connected = Boolean(snapshotArg.connected && snapshotArg.deviceInfo.online !== false);
|
||||
const deviceInfo = {
|
||||
...this.deviceInfo(connected),
|
||||
...snapshotArg.deviceInfo,
|
||||
online: connected,
|
||||
};
|
||||
return {
|
||||
...snapshotArg,
|
||||
deviceInfo,
|
||||
cameras: (snapshotArg.cameras || []).map((cameraArg) => this.normalizeCamera(cameraArg, deviceInfo, connected)),
|
||||
sensors: snapshotArg.sensors || [],
|
||||
binarySensors: snapshotArg.binarySensors || [],
|
||||
switches: snapshotArg.switches || [],
|
||||
events: snapshotArg.events || [],
|
||||
currentSettings: snapshotArg.currentSettings || {},
|
||||
connected,
|
||||
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeCamera(cameraArg: IAmcrestCamera, deviceInfoArg: IAmcrestDeviceInfo, connectedArg: boolean): IAmcrestCamera {
|
||||
const subtype = cameraArg.subtype ?? amcrestResolutionSubtype[cameraArg.resolution || this.resolution()];
|
||||
const baseCamera = this.camera(deviceInfoArg, connectedArg, cameraArg);
|
||||
return {
|
||||
...baseCamera,
|
||||
...cameraArg,
|
||||
subtype,
|
||||
snapshotUrl: cameraArg.snapshotUrl || baseCamera.snapshotUrl,
|
||||
mjpegUrl: cameraArg.mjpegUrl || baseCamera.mjpegUrl,
|
||||
rtspUrl: cameraArg.rtspUrl || baseCamera.rtspUrl,
|
||||
available: connectedArg && cameraArg.available !== false,
|
||||
};
|
||||
}
|
||||
|
||||
private camera(deviceInfoArg: IAmcrestDeviceInfo, connectedArg: boolean, overridesArg: Partial<IAmcrestCamera> = {}): IAmcrestCamera {
|
||||
const channel = overridesArg.channel ?? this.channel();
|
||||
const resolution = overridesArg.resolution ?? this.resolution();
|
||||
const subtype = overridesArg.subtype ?? amcrestResolutionSubtype[resolution];
|
||||
const name = overridesArg.name || `${deviceInfoArg.name || 'Amcrest'} Camera`;
|
||||
return {
|
||||
id: overridesArg.id || String(channel),
|
||||
name,
|
||||
channel,
|
||||
resolution,
|
||||
subtype,
|
||||
streamSource: overridesArg.streamSource || this.config.streamSource || 'snapshot',
|
||||
snapshotUrl: overridesArg.snapshotUrl || this.snapshotUrl(channel),
|
||||
mjpegUrl: overridesArg.mjpegUrl || this.mjpegUrl(channel, subtype),
|
||||
rtspUrl: overridesArg.rtspUrl || this.rtspUrl(channel, subtype),
|
||||
supportsPtz: overridesArg.supportsPtz ?? this.config.supportsPtz ?? true,
|
||||
isStreaming: overridesArg.isStreaming ?? true,
|
||||
isRecording: overridesArg.isRecording ?? false,
|
||||
motionDetectionEnabled: overridesArg.motionDetectionEnabled,
|
||||
audioEnabled: overridesArg.audioEnabled,
|
||||
motionRecordingEnabled: overridesArg.motionRecordingEnabled,
|
||||
colorBw: overridesArg.colorBw,
|
||||
attributes: overridesArg.attributes,
|
||||
available: connectedArg && overridesArg.available !== false,
|
||||
};
|
||||
}
|
||||
|
||||
private deviceInfo(connectedArg: boolean, liveArg: Partial<IAmcrestDeviceInfo> = {}): IAmcrestDeviceInfo {
|
||||
const endpoint = this.endpoint();
|
||||
const serialNumber = liveArg.serialNumber || this.config.deviceInfo?.serialNumber || this.config.uniqueId;
|
||||
return {
|
||||
...this.config.deviceInfo,
|
||||
...liveArg,
|
||||
id: this.config.deviceInfo?.id || this.config.uniqueId || serialNumber || endpoint.host || 'manual-amcrest',
|
||||
name: this.config.deviceInfo?.name || this.config.name || endpoint.host || 'Amcrest Camera',
|
||||
manufacturer: liveArg.manufacturer || this.config.deviceInfo?.manufacturer || this.config.manufacturer || 'Amcrest',
|
||||
model: liveArg.model || this.config.deviceInfo?.model || this.config.model,
|
||||
serialNumber,
|
||||
host: this.config.deviceInfo?.host || endpoint.host,
|
||||
port: this.config.deviceInfo?.port || endpoint.port,
|
||||
protocol: this.config.deviceInfo?.protocol || endpoint.protocol,
|
||||
rtspPort: this.config.deviceInfo?.rtspPort || this.config.rtspPort || amcrestDefaultRtspPort,
|
||||
url: this.config.deviceInfo?.url || this.baseUrl(),
|
||||
online: connectedArg,
|
||||
};
|
||||
}
|
||||
|
||||
private sensorsFromLive(presetCountArg: number | undefined, storageArg: { usedPercent?: number; total?: string; used?: string } | undefined, connectedArg: boolean): IAmcrestSensor[] {
|
||||
const enabled = new Set(this.config.enabledSensors || []);
|
||||
const sensors: IAmcrestSensor[] = [];
|
||||
if (enabled.has('ptz_preset') || presetCountArg !== undefined) {
|
||||
sensors.push({ key: 'ptz_preset', name: 'PTZ Preset', value: presetCountArg ?? 'unknown', entityCategory: 'diagnostic', available: connectedArg });
|
||||
}
|
||||
if (enabled.has('sdcard') || storageArg?.usedPercent !== undefined) {
|
||||
sensors.push({
|
||||
key: 'sdcard',
|
||||
name: 'SD Used',
|
||||
value: storageArg?.usedPercent ?? 'unknown',
|
||||
unit: '%',
|
||||
entityCategory: 'diagnostic',
|
||||
available: connectedArg,
|
||||
attributes: {
|
||||
Total: storageArg?.total,
|
||||
Used: storageArg?.used,
|
||||
},
|
||||
});
|
||||
}
|
||||
return sensors;
|
||||
}
|
||||
|
||||
private sensorsFromConfig(connectedArg: boolean): IAmcrestSensor[] {
|
||||
const enabled = new Set(this.config.enabledSensors || []);
|
||||
return amcrestSensorDescriptions
|
||||
.filter((descriptionArg) => enabled.has(descriptionArg.key))
|
||||
.map((descriptionArg) => ({
|
||||
key: descriptionArg.key,
|
||||
name: descriptionArg.name,
|
||||
value: 'unknown',
|
||||
unit: descriptionArg.unit,
|
||||
deviceClass: descriptionArg.deviceClass,
|
||||
entityCategory: descriptionArg.entityCategory,
|
||||
available: connectedArg,
|
||||
}));
|
||||
}
|
||||
|
||||
private binarySensorsFromEvents(eventStatesArg: Record<string, boolean | undefined>, connectedArg: boolean): IAmcrestBinarySensor[] {
|
||||
const defaultKeys = ['online', 'motion_detected'];
|
||||
const enabled = new Set(this.config.enabledBinarySensors || this.config.snapshot?.binarySensors?.map((sensorArg) => sensorArg.key) || defaultKeys);
|
||||
return amcrestBinarySensorDescriptions
|
||||
.filter((descriptionArg) => enabled.has(descriptionArg.key))
|
||||
.map((descriptionArg) => {
|
||||
const isOnline = descriptionArg.key === 'online';
|
||||
const eventOn = descriptionArg.eventCodes?.some((codeArg) => eventStatesArg[codeArg] === true) ?? false;
|
||||
return {
|
||||
key: descriptionArg.key,
|
||||
name: descriptionArg.name,
|
||||
isOn: isOnline ? connectedArg : eventOn,
|
||||
deviceClass: descriptionArg.deviceClass,
|
||||
eventCodes: descriptionArg.eventCodes,
|
||||
shouldPoll: descriptionArg.shouldPoll,
|
||||
available: isOnline || connectedArg,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private switchesFromSettings(settingsArg: Record<string, unknown>, connectedArg: boolean): IAmcrestSwitch[] {
|
||||
const enabled = new Set(this.config.enabledSwitches || this.config.snapshot?.switches?.map((switchArg) => switchArg.key) || ['privacy_mode']);
|
||||
return amcrestSwitchDescriptions
|
||||
.filter((descriptionArg) => enabled.has(descriptionArg.key))
|
||||
.map((descriptionArg) => ({
|
||||
key: descriptionArg.key,
|
||||
name: descriptionArg.name,
|
||||
isOn: booleanValue(settingsArg[descriptionArg.key]) ?? false,
|
||||
command: descriptionArg.command,
|
||||
entityCategory: descriptionArg.entityCategory,
|
||||
available: connectedArg,
|
||||
}));
|
||||
}
|
||||
|
||||
private eventsFromEventStates(eventStatesArg: Record<string, boolean | undefined>): IAmcrestEvent[] {
|
||||
return Object.entries(eventStatesArg)
|
||||
.filter(([, valueArg]) => valueArg !== undefined)
|
||||
.map(([codeArg, isOnArg]) => ({
|
||||
id: codeArg,
|
||||
name: codeArg,
|
||||
code: codeArg,
|
||||
isOn: isOnArg,
|
||||
state: isOnArg ? 'on' : 'off',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
private async setBooleanConfig(kindArg: 'privacy_mode' | 'video' | 'audio' | 'motion_detection' | 'motion_recording', enabledArg: boolean, channelArg?: number): Promise<IAmcrestCommandResponse> {
|
||||
const channel = channelArg ?? this.channel();
|
||||
const subtype = this.subtype();
|
||||
const streamFormat = this.streamFormat(subtype);
|
||||
const value = String(enabledArg).toLowerCase();
|
||||
const field = kindArg === 'privacy_mode'
|
||||
? `LeLensMask[${channel}].Enable`
|
||||
: kindArg === 'video'
|
||||
? `Encode[${channel}].${streamFormat}[0].VideoEnable`
|
||||
: kindArg === 'audio'
|
||||
? `Encode[${channel}].${streamFormat}[0].AudioEnable`
|
||||
: kindArg === 'motion_detection'
|
||||
? `MotionDetect[${channel}].Enable`
|
||||
: `MotionDetect[${channel}].EventHandler.RecordEnable`;
|
||||
const command = this.httpCommand(kindArg, `/cgi-bin/configManager.cgi?action=setConfig&${field}=${value}`);
|
||||
return this.requestOk(command);
|
||||
}
|
||||
|
||||
private async setRecordMode(enabledArg: boolean, channelArg?: number): Promise<IAmcrestCommandResponse> {
|
||||
const channel = channelArg ?? this.channel();
|
||||
return this.requestOk(this.httpCommand('recording', `/cgi-bin/configManager.cgi?action=setConfig&RecordMode[${channel}].Mode=${enabledArg ? 1 : 0}`));
|
||||
}
|
||||
|
||||
private async setColorBw(colorBwArg: TAmcrestColorBw, channelArg?: number): Promise<IAmcrestCommandResponse> {
|
||||
const channel = channelArg ?? this.channel();
|
||||
const modeIndex = amcrestColorModes.indexOf(colorBwArg);
|
||||
return this.requestOk(this.httpCommand('color_bw', `/cgi-bin/configManager.cgi?action=setConfig&VideoInMode[${channel}].Config[0]=${modeIndex}`));
|
||||
}
|
||||
|
||||
private async gotoPreset(presetArg: number, channelArg?: number): Promise<IAmcrestCommandResponse> {
|
||||
const channel = channelArg ?? this.channel();
|
||||
return this.requestOk(this.httpCommand('goto_preset', `/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=GotoPreset&arg1=0&arg2=${Math.round(presetArg)}&arg3=0`));
|
||||
}
|
||||
|
||||
private async ptzControl(movementArg: TAmcrestPtzMovement, travelTimeArg = 0.2, channelArg?: number): Promise<IAmcrestCommandResponse[]> {
|
||||
const channel = channelArg ?? this.channel();
|
||||
const code = ptzCodes[movementArg];
|
||||
let arg1 = 0;
|
||||
let arg2 = 0;
|
||||
if (ptzMoveOneArg2.has(code)) {
|
||||
arg2 = 1;
|
||||
} else if (ptzMoveBothArgs.has(code)) {
|
||||
arg1 = 1;
|
||||
arg2 = 1;
|
||||
}
|
||||
const query = `channel=${channel}&code=${code}&arg1=${arg1}&arg2=${arg2}&arg3=0`;
|
||||
const start = await this.requestOk(this.httpCommand('ptz_start', `/cgi-bin/ptz.cgi?action=start&${query}`));
|
||||
await sleep(Math.max(0, Math.min(1, travelTimeArg)) * 1000);
|
||||
const stop = await this.requestOk(this.httpCommand('ptz_stop', `/cgi-bin/ptz.cgi?action=stop&${query}`));
|
||||
return [start, stop];
|
||||
}
|
||||
|
||||
private async tour(startArg: boolean, channelArg?: number): Promise<IAmcrestCommandResponse> {
|
||||
const channel = channelArg ?? this.channel();
|
||||
return this.requestOk(this.httpCommand(startArg ? 'start_tour' : 'stop_tour', `/cgi-bin/ptz.cgi?action=${startArg ? 'start' : 'stop'}&channel=${channel}&code=StartTour&arg1=0&arg2=0&arg3=0`));
|
||||
}
|
||||
|
||||
private async requestOk(commandArg: IAmcrestHttpCommand): Promise<IAmcrestCommandResponse> {
|
||||
const response = await this.request(commandArg.path);
|
||||
const responseText = await response.text();
|
||||
if (!this.commandSucceeded(responseText)) {
|
||||
throw new Error(`Amcrest ${commandArg.label} command did not return a successful response: ${responseText.slice(0, 200)}`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
label: commandArg.label,
|
||||
method: commandArg.method,
|
||||
path: commandArg.path,
|
||||
status: response.status,
|
||||
responseText,
|
||||
};
|
||||
}
|
||||
|
||||
private commandSucceeded(valueArg: string): boolean {
|
||||
const value = valueArg.trim().toLowerCase();
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
if (value.includes('error') || value.includes('fail')) {
|
||||
return false;
|
||||
}
|
||||
if (value === 'false' || /result\s*=\s*false/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async requestText(pathArg: string): Promise<string> {
|
||||
return (await this.request(pathArg)).text();
|
||||
}
|
||||
|
||||
private async request(pathArg: string, initArg: RequestInit = {}, timeoutMsArg = this.config.timeoutMs || amcrestDefaultTimeoutMs): Promise<Response> {
|
||||
const baseUrl = this.baseUrl();
|
||||
if (!baseUrl) {
|
||||
throw new Error('Amcrest live HTTP client requires config.host or config.url.');
|
||||
}
|
||||
const url = `${baseUrl}${pathArg.startsWith('/') ? pathArg : `/${pathArg}`}`;
|
||||
const headers = new Headers(initArg.headers);
|
||||
if (this.authScheme() === 'basic') {
|
||||
headers.set('authorization', this.basicAuthorization());
|
||||
}
|
||||
const response = await this.fetchWithTimeout(url, { ...initArg, method: initArg.method || 'GET', headers }, timeoutMsArg);
|
||||
if (response.status === 401 && this.authScheme() !== 'basic') {
|
||||
const challenge = response.headers.get('www-authenticate') || '';
|
||||
const retryHeaders = new Headers(initArg.headers);
|
||||
if (/digest/i.test(challenge)) {
|
||||
const requestUrl = new URL(url);
|
||||
retryHeaders.set('authorization', this.digestAuthorization(challenge, initArg.method || 'GET', `${requestUrl.pathname}${requestUrl.search}`));
|
||||
return this.checkedResponse(await this.fetchWithTimeout(url, { ...initArg, method: initArg.method || 'GET', headers: retryHeaders }, timeoutMsArg), pathArg);
|
||||
}
|
||||
if (/basic/i.test(challenge) || this.authScheme() === 'auto') {
|
||||
retryHeaders.set('authorization', this.basicAuthorization());
|
||||
return this.checkedResponse(await this.fetchWithTimeout(url, { ...initArg, method: initArg.method || 'GET', headers: retryHeaders }, timeoutMsArg), pathArg);
|
||||
}
|
||||
}
|
||||
return this.checkedResponse(response, pathArg);
|
||||
}
|
||||
|
||||
private async checkedResponse(responseArg: Response, pathArg: string): Promise<Response> {
|
||||
if (!responseArg.ok) {
|
||||
const text = await responseArg.text().catch(() => '');
|
||||
if (responseArg.status === 401) {
|
||||
throw new AmcrestHttpError(responseArg.status, 'Amcrest authentication failed.');
|
||||
}
|
||||
throw new AmcrestHttpError(responseArg.status, `Amcrest request ${pathArg} failed with HTTP ${responseArg.status}${text ? `: ${text}` : ''}`);
|
||||
}
|
||||
return responseArg;
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(urlArg: string, initArg: RequestInit, timeoutMsArg: number): Promise<Response> {
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), timeoutMsArg);
|
||||
try {
|
||||
return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private digestAuthorization(challengeArg: string, methodArg: string, uriArg: string): string {
|
||||
const challenge = parseDigestChallenge(challengeArg);
|
||||
if (!challenge.realm || !challenge.nonce) {
|
||||
throw new Error('Amcrest digest authentication challenge is missing realm or nonce.');
|
||||
}
|
||||
const algorithm = (challenge.algorithm || 'MD5').toUpperCase();
|
||||
if (algorithm !== 'MD5' && algorithm !== 'MD5-SESS') {
|
||||
throw new Error(`Amcrest digest authentication algorithm is unsupported: ${algorithm}`);
|
||||
}
|
||||
const qop = splitCsv(challenge.qop).includes('auth') ? 'auth' : undefined;
|
||||
const cnonce = plugins.crypto.randomBytes(8).toString('hex');
|
||||
const nc = '00000001';
|
||||
const username = this.config.username || '';
|
||||
const password = this.config.password || '';
|
||||
const ha1Raw = md5(`${username}:${challenge.realm}:${password}`);
|
||||
const ha1 = algorithm === 'MD5-SESS' ? md5(`${ha1Raw}:${challenge.nonce}:${cnonce}`) : ha1Raw;
|
||||
const ha2 = md5(`${methodArg.toUpperCase()}:${uriArg}`);
|
||||
const response = qop ? md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}`) : md5(`${ha1}:${challenge.nonce}:${ha2}`);
|
||||
const parts: Record<string, string> = {
|
||||
username,
|
||||
realm: challenge.realm,
|
||||
nonce: challenge.nonce,
|
||||
uri: uriArg,
|
||||
response,
|
||||
algorithm,
|
||||
};
|
||||
if (challenge.opaque) {
|
||||
parts.opaque = challenge.opaque;
|
||||
}
|
||||
if (qop) {
|
||||
parts.qop = qop;
|
||||
parts.nc = nc;
|
||||
parts.cnonce = cnonce;
|
||||
}
|
||||
return `Digest ${Object.entries(parts).map(([keyArg, valueArg]) => keyArg === 'qop' || keyArg === 'nc' || keyArg === 'algorithm' ? `${keyArg}=${valueArg}` : `${keyArg}="${valueArg.replace(/"/g, '\\"')}"`).join(', ')}`;
|
||||
}
|
||||
|
||||
private basicAuthorization(): string {
|
||||
return `Basic ${Buffer.from(`${this.config.username || ''}:${this.config.password || ''}`, 'utf8').toString('base64')}`;
|
||||
}
|
||||
|
||||
private httpCommand(labelArg: string, pathArg: string): IAmcrestHttpCommand {
|
||||
return { label: labelArg, method: 'GET', path: pathArg, expect: 'ok' };
|
||||
}
|
||||
|
||||
private snapshotHttpCommand(channelArg?: number): IAmcrestHttpCommand {
|
||||
return { label: 'snapshot', method: 'GET', path: `/cgi-bin/snapshot.cgi?channel=${channelArg ?? this.channel()}`, expect: 'image' };
|
||||
}
|
||||
|
||||
private snapshotUrl(channelArg: number): string | undefined {
|
||||
const baseUrl = this.baseUrl();
|
||||
return baseUrl ? `${baseUrl}${this.snapshotHttpCommand(channelArg).path}` : undefined;
|
||||
}
|
||||
|
||||
private mjpegUrl(channelArg: number, subtypeArg: 0 | 1): string | undefined {
|
||||
const baseUrl = this.baseUrl();
|
||||
return baseUrl ? `${baseUrl}/cgi-bin/mjpg/video.cgi?channel=${channelArg}&subtype=${subtypeArg}` : undefined;
|
||||
}
|
||||
|
||||
private rtspUrl(channelArg: number, subtypeArg: 0 | 1): string | undefined {
|
||||
const endpoint = this.endpoint();
|
||||
if (!endpoint.host) {
|
||||
return undefined;
|
||||
}
|
||||
const credentials = this.rtspCredentials();
|
||||
return `rtsp://${credentials}${endpoint.host}:${this.config.rtspPort || amcrestDefaultRtspPort}/cam/realmonitor?channel=${channelArg + 1}&subtype=${subtypeArg}`;
|
||||
}
|
||||
|
||||
private streamSourceUrl(cameraArg: IAmcrestCamera, streamSourceArg?: TAmcrestStreamSource): string | undefined {
|
||||
const streamSource = streamSourceArg || cameraArg.streamSource;
|
||||
if (streamSource === 'rtsp') {
|
||||
return cameraArg.rtspUrl;
|
||||
}
|
||||
if (streamSource === 'mjpeg') {
|
||||
return cameraArg.mjpegUrl;
|
||||
}
|
||||
return cameraArg.snapshotUrl;
|
||||
}
|
||||
|
||||
private baseUrl(): string | undefined {
|
||||
if (this.config.url) {
|
||||
const url = safeUrl(this.config.url);
|
||||
if (url) {
|
||||
return `${url.protocol}//${url.hostname}:${url.port || (url.protocol === 'https:' ? 443 : amcrestDefaultPort)}`;
|
||||
}
|
||||
}
|
||||
const endpoint = this.endpoint();
|
||||
if (!endpoint.host) {
|
||||
return undefined;
|
||||
}
|
||||
return `${endpoint.protocol}://${endpoint.host}:${endpoint.port || amcrestDefaultPort}`;
|
||||
}
|
||||
|
||||
private endpoint(): { protocol: TAmcrestProtocol; 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 : amcrestDefaultPort,
|
||||
};
|
||||
}
|
||||
return {
|
||||
protocol: this.config.protocol || 'http',
|
||||
host: this.config.host,
|
||||
port: this.config.port || amcrestDefaultPort,
|
||||
};
|
||||
}
|
||||
|
||||
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 authScheme(): TAmcrestAuthScheme {
|
||||
return this.config.authScheme || 'auto';
|
||||
}
|
||||
|
||||
private resolution(): TAmcrestResolution {
|
||||
return this.config.resolution || 'high';
|
||||
}
|
||||
|
||||
private subtype(): 0 | 1 {
|
||||
return amcrestResolutionSubtype[this.resolution()];
|
||||
}
|
||||
|
||||
private streamFormat(subtypeArg: 0 | 1): string {
|
||||
return `${amcrestSubtypeStream[subtypeArg]}Format`;
|
||||
}
|
||||
|
||||
private channel(): number {
|
||||
return Number.isInteger(this.config.channel) && this.config.channel! >= 0 ? this.config.channel! : 0;
|
||||
}
|
||||
|
||||
private requireEnabled(commandArg: IAmcrestClientCommand): boolean {
|
||||
if (typeof commandArg.enabled !== 'boolean') {
|
||||
throw new Error(`Amcrest ${commandArg.type} requires a boolean enabled value.`);
|
||||
}
|
||||
return commandArg.enabled;
|
||||
}
|
||||
|
||||
private findCamera(snapshotArg: IAmcrestSnapshot, cameraIdArg?: string): IAmcrestCamera {
|
||||
const cameraId = cameraIdArg || '';
|
||||
const camera = snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.channel) === cameraId) || snapshotArg.cameras[0];
|
||||
if (!camera) {
|
||||
throw new Error('Amcrest camera command requires a configured or discovered camera.');
|
||||
}
|
||||
return camera;
|
||||
}
|
||||
|
||||
private patchCachedCamera(valuesArg: Partial<IAmcrestCamera>): void {
|
||||
if (!this.snapshot) {
|
||||
return;
|
||||
}
|
||||
for (const camera of this.snapshot.cameras) {
|
||||
Object.assign(camera, valuesArg);
|
||||
}
|
||||
}
|
||||
|
||||
private patchCachedSwitch(keyArg: string, isOnArg: boolean): void {
|
||||
if (!this.snapshot) {
|
||||
return;
|
||||
}
|
||||
this.snapshot.currentSettings[keyArg] = isOnArg;
|
||||
for (const switchArg of this.snapshot.switches) {
|
||||
if (switchArg.key === keyArg) {
|
||||
switchArg.isOn = isOnArg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private colorModeFromValue(valueArg: unknown): TAmcrestColorBw | undefined {
|
||||
if (typeof valueArg === 'number') {
|
||||
return amcrestColorModes[valueArg];
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
const numeric = Number(valueArg);
|
||||
if (Number.isInteger(numeric)) {
|
||||
return amcrestColorModes[numeric];
|
||||
}
|
||||
return amcrestColorModes.find((modeArg) => modeArg === valueArg.toLowerCase());
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private eventTextIsOn(textArg: string | undefined): boolean | undefined {
|
||||
if (textArg === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const text = textArg.trim().toLowerCase();
|
||||
if (!text || text.includes('error') || text.includes('false')) {
|
||||
return false;
|
||||
}
|
||||
return /channels\s*\[\s*\d+\s*\]\s*=/.test(text) || /\bindex(?:es)?\s*\[\s*\d+\s*\]\s*=/.test(text) || text.includes('true');
|
||||
}
|
||||
|
||||
private presetCount(textArg: string | undefined): number | undefined {
|
||||
if (textArg === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const matches = new Set<string>();
|
||||
for (const match of textArg.matchAll(/(?:presets?|Preset)\[(\d+)\]/g)) {
|
||||
matches.add(match[1]);
|
||||
}
|
||||
return matches.size;
|
||||
}
|
||||
|
||||
private storageFromText(textArg: string | undefined): { usedPercent?: number; total?: string; used?: string } | undefined {
|
||||
if (!textArg) {
|
||||
return undefined;
|
||||
}
|
||||
const values = parseKeyValues(textArg);
|
||||
let totalBytes: number | undefined;
|
||||
let usedBytes: number | undefined;
|
||||
let usedPercent: number | undefined;
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
const number = numberValue(value);
|
||||
if (number === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (lowerKey.includes('percent')) {
|
||||
usedPercent = number;
|
||||
} else if (lowerKey.includes('total') && (lowerKey.includes('byte') || lowerKey.includes('space'))) {
|
||||
totalBytes = number;
|
||||
} else if (lowerKey.includes('used') && (lowerKey.includes('byte') || lowerKey.includes('space'))) {
|
||||
usedBytes = number;
|
||||
}
|
||||
}
|
||||
if (usedPercent === undefined && totalBytes && usedBytes !== undefined) {
|
||||
usedPercent = Number(((usedBytes / totalBytes) * 100).toFixed(2));
|
||||
}
|
||||
if (usedPercent === undefined && totalBytes === undefined && usedBytes === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
usedPercent,
|
||||
total: totalBytes === undefined ? undefined : formatBytes(totalBytes),
|
||||
used: usedBytes === undefined ? undefined : formatBytes(usedBytes),
|
||||
};
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IAmcrestSnapshot): IAmcrestSnapshot {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as IAmcrestSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
const md5 = (valueArg: string): string => plugins.crypto.createHash('md5').update(valueArg).digest('hex');
|
||||
|
||||
const sleep = (msArg: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, msArg));
|
||||
|
||||
const parseDigestChallenge = (valueArg: string): Record<string, string> => {
|
||||
const result: Record<string, string> = {};
|
||||
const challenge = valueArg.replace(/^\s*Digest\s+/i, '');
|
||||
const matcher = /([a-zA-Z0-9_-]+)=(?:"([^"]*)"|([^,\s]+))/g;
|
||||
for (const match of challenge.matchAll(matcher)) {
|
||||
result[match[1].toLowerCase()] = match[2] ?? match[3] ?? '';
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const splitCsv = (valueArg: string | undefined): string[] => {
|
||||
return (valueArg || '').split(',').map((entryArg) => entryArg.trim()).filter(Boolean);
|
||||
};
|
||||
|
||||
const safeUrl = (valueArg: string | undefined): URL | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(valueArg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const parseKeyValues = (textArg: string | undefined): Record<string, string> => {
|
||||
const values: Record<string, string> = {};
|
||||
for (const line of (textArg || '').split(/\r?\n/)) {
|
||||
const [rawKey, ...rawValue] = line.split('=');
|
||||
if (!rawKey || !rawValue.length) {
|
||||
continue;
|
||||
}
|
||||
const key = rawKey.trim().replace(/^table\./, '');
|
||||
values[key] = rawValue.join('=').trim();
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const firstPlainValue = (textArg: string | undefined): string | undefined => {
|
||||
const line = (textArg || '').split(/\r?\n/).map((entryArg) => entryArg.trim()).find(Boolean);
|
||||
if (!line) {
|
||||
return undefined;
|
||||
}
|
||||
const [, ...rest] = line.split('=');
|
||||
return rest.length ? rest.join('=').trim() || undefined : line;
|
||||
};
|
||||
|
||||
const valueBySuffix = (valuesArg: Record<string, string>, suffixesArg: string[]): string | undefined => {
|
||||
for (const suffix of suffixesArg) {
|
||||
const exact = valuesArg[suffix];
|
||||
if (exact !== undefined) {
|
||||
return exact;
|
||||
}
|
||||
const entry = Object.entries(valuesArg).find(([key]) => key.endsWith(suffix));
|
||||
if (entry) {
|
||||
return entry[1];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const booleanValue = (valueArg: unknown): boolean | undefined => {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'number') {
|
||||
return valueArg !== 0;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
if (['true', 'yes', 'on', '1', 'manual'].includes(valueArg.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
if (['false', 'no', 'off', '0', 'automatic'].includes(valueArg.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const numberValue = (valueArg: unknown): number | undefined => {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const value = Number(valueArg);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const formatBytes = (valueArg: number): string => {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = valueArg;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`;
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IAmcrestConfig, TAmcrestAuthScheme, TAmcrestProtocol, TAmcrestResolution, TAmcrestStreamSource } from './amcrest.types.js';
|
||||
import { amcrestDefaultPort, amcrestDefaultTimeoutMs } from './amcrest.types.js';
|
||||
|
||||
export class AmcrestConfigFlow implements IConfigFlow<IAmcrestConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAmcrestConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Amcrest camera',
|
||||
description: 'Configure the local Amcrest HTTP CGI endpoint. Use a base URL or host plus port.',
|
||||
fields: [
|
||||
{ name: 'url', label: 'Base URL', type: 'text' },
|
||||
{ name: 'host', label: 'Host', type: 'text' },
|
||||
{ name: 'port', label: 'Port', type: 'number' },
|
||||
{ name: 'username', label: 'Username', type: 'text', required: true },
|
||||
{ name: 'password', label: 'Password', type: 'password', required: true },
|
||||
{ name: 'authScheme', label: 'Authentication', type: 'select', options: [{ label: 'Auto', value: 'auto' }, { label: 'Basic', value: 'basic' }, { label: 'Digest', value: 'digest' }] },
|
||||
{ name: 'streamSource', label: 'Stream source', type: 'select', options: [{ label: 'Snapshot', value: 'snapshot' }, { label: 'MJPEG', value: 'mjpeg' }, { label: 'RTSP', value: 'rtsp' }] },
|
||||
{ name: 'resolution', label: 'Resolution', type: 'select', options: [{ label: 'High', value: 'high' }, { label: 'Low', value: 'low' }] },
|
||||
{ name: 'channel', label: 'Channel', type: 'number' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const urlValue = this.stringValue(valuesArg.url) || this.stringMetadata(candidateArg, 'url');
|
||||
const endpoint = this.endpoint(urlValue, this.stringValue(valuesArg.host) || candidateArg.host, this.numberValue(valuesArg.port) || candidateArg.port, this.protocolMetadata(candidateArg));
|
||||
if (!endpoint.host) {
|
||||
return { kind: 'error', error: 'Amcrest requires a base URL or host.' };
|
||||
}
|
||||
const username = this.stringValue(valuesArg.username) || this.stringMetadata(candidateArg, 'username');
|
||||
const password = this.stringValue(valuesArg.password) || this.stringMetadata(candidateArg, 'password');
|
||||
if (!username || password === undefined) {
|
||||
return { kind: 'error', error: 'Amcrest requires username and password.' };
|
||||
}
|
||||
const channel = this.numberValue(valuesArg.channel);
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Amcrest camera configured',
|
||||
config: {
|
||||
protocol: endpoint.protocol,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
url: endpoint.url,
|
||||
username,
|
||||
password,
|
||||
authScheme: this.authSchemeValue(valuesArg.authScheme) || 'auto',
|
||||
streamSource: this.streamSourceValue(valuesArg.streamSource) || 'snapshot',
|
||||
resolution: this.resolutionValue(valuesArg.resolution) || 'high',
|
||||
channel: channel === undefined ? 0 : Math.max(0, Math.floor(channel)),
|
||||
name: this.stringValue(valuesArg.name) || candidateArg.name || endpoint.host,
|
||||
uniqueId: candidateArg.id || candidateArg.macAddress || candidateArg.serialNumber || endpoint.host,
|
||||
manufacturer: candidateArg.manufacturer || 'Amcrest',
|
||||
model: candidateArg.model,
|
||||
timeoutMs: amcrestDefaultTimeoutMs,
|
||||
controlLight: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private endpoint(urlArg: string | undefined, hostArg: string | undefined, portArg: number | undefined, protocolArg: TAmcrestProtocol | undefined): { protocol: TAmcrestProtocol; host?: string; port: number; url?: string } {
|
||||
const url = safeUrl(urlArg || hostArg);
|
||||
if (url) {
|
||||
const protocol = url.protocol === 'https:' ? 'https' : 'http';
|
||||
const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : amcrestDefaultPort;
|
||||
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}` };
|
||||
}
|
||||
const protocol = protocolArg || 'http';
|
||||
const port = portArg || amcrestDefaultPort;
|
||||
return { protocol, host: hostArg, port, url: hostArg ? `${protocol}://${hostArg}:${port}` : undefined };
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const value = Number(valueArg);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private stringMetadata(candidateArg: IDiscoveryCandidate, keyArg: string): string | undefined {
|
||||
const value = candidateArg.metadata?.[keyArg];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
private protocolMetadata(candidateArg: IDiscoveryCandidate): TAmcrestProtocol | undefined {
|
||||
const protocol = candidateArg.metadata?.protocol;
|
||||
return protocol === 'http' || protocol === 'https' ? protocol : undefined;
|
||||
}
|
||||
|
||||
private authSchemeValue(valueArg: unknown): TAmcrestAuthScheme | undefined {
|
||||
return valueArg === 'auto' || valueArg === 'basic' || valueArg === 'digest' ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private streamSourceValue(valueArg: unknown): TAmcrestStreamSource | undefined {
|
||||
return valueArg === 'snapshot' || valueArg === 'mjpeg' || valueArg === 'rtsp' ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private resolutionValue(valueArg: unknown): TAmcrestResolution | undefined {
|
||||
return valueArg === 'high' || valueArg === 'low' ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const safeUrl = (valueArg: string | undefined): URL | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(valueArg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -1,28 +1,83 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { AmcrestClient } from './amcrest.classes.client.js';
|
||||
import { AmcrestConfigFlow } from './amcrest.classes.configflow.js';
|
||||
import { createAmcrestDiscoveryDescriptor } from './amcrest.discovery.js';
|
||||
import { AmcrestMapper } from './amcrest.mapper.js';
|
||||
import type { IAmcrestConfig } from './amcrest.types.js';
|
||||
|
||||
export class HomeAssistantAmcrestIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "amcrest",
|
||||
displayName: "Amcrest",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/amcrest",
|
||||
"upstreamDomain": "amcrest",
|
||||
"iotClass": "local_polling",
|
||||
"qualityScale": "legacy",
|
||||
"requirements": [
|
||||
"amcrest==1.9.9"
|
||||
],
|
||||
"dependencies": [
|
||||
"ffmpeg"
|
||||
],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@flacjacket"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class AmcrestIntegration extends BaseIntegration<IAmcrestConfig> {
|
||||
public readonly domain = 'amcrest';
|
||||
public readonly displayName = 'Amcrest';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createAmcrestDiscoveryDescriptor();
|
||||
public readonly configFlow = new AmcrestConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/amcrest',
|
||||
upstreamDomain: 'amcrest',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
qualityScale: 'legacy',
|
||||
requirements: ['amcrest==1.9.9'],
|
||||
dependencies: ['ffmpeg'],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@flacjacket'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/amcrest',
|
||||
nativePort: {
|
||||
manualLocalDiscovery: true,
|
||||
snapshotMapping: true,
|
||||
liveHttpCgiCommands: true,
|
||||
liveEvents: false,
|
||||
rtspProxying: false,
|
||||
ffmpegProxying: false,
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IAmcrestConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new AmcrestRuntime(new AmcrestClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantAmcrestIntegration extends AmcrestIntegration {}
|
||||
|
||||
class AmcrestRuntime implements IIntegrationRuntime {
|
||||
public domain = 'amcrest';
|
||||
|
||||
constructor(private readonly client: AmcrestClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return AmcrestMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return AmcrestMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||
void handlerArg;
|
||||
throw new Error('Amcrest live event streaming is not implemented in this TypeScript port; use polled binary sensors or refresh snapshots.');
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const command = AmcrestMapper.commandForService(snapshot, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported Amcrest service: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
const data = await this.client.execute(command);
|
||||
return { success: true, data };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { IAmcrestManualEntry, IAmcrestMdnsRecord, IAmcrestSsdpRecord, TAmcrestProtocol } from './amcrest.types.js';
|
||||
import { amcrestDefaultPort } from './amcrest.types.js';
|
||||
|
||||
export class AmcrestManualMatcher implements IDiscoveryMatcher<IAmcrestManualEntry> {
|
||||
public id = 'amcrest-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Amcrest local camera host or base URL entries.';
|
||||
|
||||
public async matches(inputArg: IAmcrestManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const endpoint = endpointFromInput(inputArg);
|
||||
const hint = hasAmcrestHint(inputArg.name, inputArg.manufacturer, inputArg.model) || Boolean(inputArg.metadata?.amcrest || inputArg.metadata?.dahua);
|
||||
if (!endpoint.host && !hint) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual Amcrest entry requires host, url, or Amcrest metadata.' };
|
||||
}
|
||||
const normalizedDeviceId = normalizeMac(inputArg.macAddress || inputArg.id || inputArg.serialNumber) || inputArg.id || inputArg.serialNumber || endpoint.host || endpoint.url;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: endpoint.host ? 'high' : 'medium',
|
||||
reason: endpoint.host ? 'Manual entry contains a local Amcrest camera endpoint.' : 'Manual entry contains Amcrest metadata.',
|
||||
normalizedDeviceId,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: 'amcrest',
|
||||
id: normalizedDeviceId,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
name: inputArg.name || endpoint.host,
|
||||
manufacturer: inputArg.manufacturer || 'Amcrest',
|
||||
model: inputArg.model,
|
||||
serialNumber: inputArg.serialNumber,
|
||||
macAddress: normalizeMac(inputArg.macAddress) || undefined,
|
||||
metadata: {
|
||||
...inputArg.metadata,
|
||||
protocol: endpoint.protocol,
|
||||
url: endpoint.url,
|
||||
username: inputArg.username,
|
||||
password: inputArg.password,
|
||||
discoveryProtocol: 'manual',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
protocol: endpoint.protocol,
|
||||
url: endpoint.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class AmcrestMdnsMatcher implements IDiscoveryMatcher<IAmcrestMdnsRecord> {
|
||||
public id = 'amcrest-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize local Amcrest-style mDNS records by host, name, and TXT metadata.';
|
||||
|
||||
public async matches(recordArg: IAmcrestMdnsRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const properties = { ...recordArg.txt, ...recordArg.properties };
|
||||
const manufacturer = valueForKey(properties, 'manufacturer') || valueForKey(properties, 'vendor');
|
||||
const model = valueForKey(properties, 'model') || valueForKey(properties, 'product');
|
||||
const serial = valueForKey(properties, 'serial') || valueForKey(properties, 'serialNumber');
|
||||
const mac = normalizeMac(valueForKey(properties, 'mac') || valueForKey(properties, 'macAddress') || serial);
|
||||
const name = cleanMdnsName(recordArg.name || recordArg.hostname);
|
||||
const matched = hasAmcrestHint(name, manufacturer, model) || Boolean(valueForKey(properties, 'amcrest'));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record does not contain Amcrest camera hints.' };
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
confidence: 'high',
|
||||
reason: 'mDNS record contains Amcrest camera metadata.',
|
||||
normalizedDeviceId: mac || serial || recordArg.host,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: 'amcrest',
|
||||
id: mac || serial || recordArg.host,
|
||||
host: recordArg.host || recordArg.addresses?.[0],
|
||||
port: recordArg.port || amcrestDefaultPort,
|
||||
name: name || undefined,
|
||||
manufacturer: manufacturer || 'Amcrest',
|
||||
model,
|
||||
serialNumber: serial,
|
||||
macAddress: mac || undefined,
|
||||
metadata: {
|
||||
mdnsName: recordArg.name,
|
||||
mdnsType: recordArg.type,
|
||||
txt: properties,
|
||||
protocol: 'http' satisfies TAmcrestProtocol,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class AmcrestSsdpMatcher implements IDiscoveryMatcher<IAmcrestSsdpRecord> {
|
||||
public id = 'amcrest-ssdp-match';
|
||||
public source = 'ssdp' as const;
|
||||
public description = 'Recognize local Amcrest cameras from SSDP manufacturer and UPnP metadata.';
|
||||
|
||||
public async matches(recordArg: IAmcrestSsdpRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const upnp = { ...recordArg.headers, ...recordArg.upnp };
|
||||
const manufacturer = recordArg.manufacturer || valueForKey(upnp, 'manufacturer') || '';
|
||||
const model = valueForKey(upnp, 'modelName') || valueForKey(upnp, 'modelNumber');
|
||||
const friendlyName = valueForKey(upnp, 'friendlyName') || valueForKey(upnp, 'upnp:friendlyName');
|
||||
const location = recordArg.location || valueForKey(upnp, 'location') || valueForKey(upnp, 'presentationURL') || valueForKey(upnp, 'presentation_url');
|
||||
const url = safeUrl(location);
|
||||
const serial = valueForKey(upnp, 'serialNumber') || valueForKey(upnp, 'serial') || recordArg.usn;
|
||||
const mac = normalizeMac(serial);
|
||||
const matched = hasAmcrestHint(friendlyName, manufacturer, model) || hasAmcrestHint(recordArg.server);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'SSDP record is not published by an Amcrest-like camera.' };
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
confidence: 'high',
|
||||
reason: 'SSDP record contains Amcrest camera metadata.',
|
||||
normalizedDeviceId: mac || serial,
|
||||
candidate: {
|
||||
source: 'ssdp',
|
||||
integrationDomain: 'amcrest',
|
||||
id: mac || serial,
|
||||
host: url?.hostname,
|
||||
port: url?.port ? Number(url.port) : amcrestDefaultPort,
|
||||
name: friendlyName,
|
||||
manufacturer: manufacturer || 'Amcrest',
|
||||
model,
|
||||
serialNumber: serial,
|
||||
macAddress: mac || undefined,
|
||||
metadata: {
|
||||
protocol: url?.protocol === 'https:' ? 'https' : 'http',
|
||||
location,
|
||||
ssdp: upnp,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class AmcrestCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'amcrest-candidate-validator';
|
||||
public description = 'Validate that a candidate can be configured as a local Amcrest camera.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
if (candidateArg.integrationDomain && candidateArg.integrationDomain !== 'amcrest') {
|
||||
return { matched: false, confidence: 'low', reason: `Candidate belongs to ${candidateArg.integrationDomain}, not Amcrest.` };
|
||||
}
|
||||
const endpoint = endpointFromCandidate(candidateArg);
|
||||
const mac = normalizeMac(candidateArg.macAddress || candidateArg.id || candidateArg.serialNumber);
|
||||
const hasHint = candidateArg.integrationDomain === 'amcrest'
|
||||
|| candidateArg.source === 'manual'
|
||||
|| hasAmcrestHint(candidateArg.name, candidateArg.manufacturer, candidateArg.model)
|
||||
|| Boolean(candidateArg.metadata?.amcrest || candidateArg.metadata?.dahua);
|
||||
if (!hasHint || !endpoint.host) {
|
||||
return { matched: false, confidence: 'low', reason: 'Amcrest candidates require a host plus manual or Amcrest camera metadata.' };
|
||||
}
|
||||
if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65535) {
|
||||
return { matched: false, confidence: 'low', reason: 'Amcrest candidate has an invalid port.' };
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
confidence: candidateArg.source === 'manual' ? 'high' : 'medium',
|
||||
reason: 'Candidate has enough local Amcrest metadata to start configuration.',
|
||||
normalizedDeviceId: candidateArg.id || mac || endpoint.host,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
integrationDomain: 'amcrest',
|
||||
id: candidateArg.id || mac || endpoint.host,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
manufacturer: candidateArg.manufacturer || 'Amcrest',
|
||||
macAddress: candidateArg.macAddress || mac || undefined,
|
||||
metadata: {
|
||||
...candidateArg.metadata,
|
||||
protocol: endpoint.protocol,
|
||||
url: endpoint.url,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
manualSupported: candidateArg.source === 'manual',
|
||||
protocol: endpoint.protocol,
|
||||
url: endpoint.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createAmcrestDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: 'amcrest', displayName: 'Amcrest' })
|
||||
.addMatcher(new AmcrestManualMatcher())
|
||||
.addMatcher(new AmcrestMdnsMatcher())
|
||||
.addMatcher(new AmcrestSsdpMatcher())
|
||||
.addValidator(new AmcrestCandidateValidator());
|
||||
};
|
||||
|
||||
const endpointFromInput = (inputArg: IAmcrestManualEntry): { protocol: TAmcrestProtocol; host?: string; port: number; url?: string } => {
|
||||
const url = safeUrl(inputArg.url || inputArg.host);
|
||||
if (url) {
|
||||
const protocol = url.protocol === 'https:' ? 'https' : 'http';
|
||||
const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : amcrestDefaultPort;
|
||||
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}` };
|
||||
}
|
||||
const protocol = inputArg.protocol || 'http';
|
||||
const port = inputArg.port || amcrestDefaultPort;
|
||||
return { protocol, host: inputArg.host, port, url: inputArg.host ? `${protocol}://${inputArg.host}:${port}` : undefined };
|
||||
};
|
||||
|
||||
const endpointFromCandidate = (candidateArg: IDiscoveryCandidate): { protocol: TAmcrestProtocol; host?: string; port: number; url?: string } => {
|
||||
const metadataUrl = typeof candidateArg.metadata?.url === 'string' ? candidateArg.metadata.url : undefined;
|
||||
const metadataProtocol = candidateArg.metadata?.protocol === 'https' ? 'https' : 'http';
|
||||
const url = safeUrl(metadataUrl || candidateArg.host);
|
||||
if (url) {
|
||||
const protocol = url.protocol === 'https:' ? 'https' : 'http';
|
||||
const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : amcrestDefaultPort;
|
||||
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}` };
|
||||
}
|
||||
const port = candidateArg.port || amcrestDefaultPort;
|
||||
return { protocol: metadataProtocol, host: candidateArg.host, port, url: candidateArg.host ? `${metadataProtocol}://${candidateArg.host}:${port}` : metadataUrl };
|
||||
};
|
||||
|
||||
const hasAmcrestHint = (...valuesArgs: Array<string | undefined>): boolean => {
|
||||
const haystack = valuesArgs.filter(Boolean).join(' ').toLowerCase();
|
||||
return haystack.includes('amcrest') || haystack.includes('dahua');
|
||||
};
|
||||
|
||||
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
|
||||
if (!recordArg) {
|
||||
return undefined;
|
||||
}
|
||||
const lowerKey = keyArg.toLowerCase();
|
||||
for (const [key, value] of Object.entries(recordArg)) {
|
||||
if (key.toLowerCase() === lowerKey) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const cleanMdnsName = (valueArg: string | undefined): string => {
|
||||
return valueArg?.replace(/\._[^.]+\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || '';
|
||||
};
|
||||
|
||||
const normalizeMac = (valueArg: string | undefined): string => {
|
||||
const cleaned = (valueArg || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase();
|
||||
return cleaned.length === 12 ? cleaned : '';
|
||||
};
|
||||
|
||||
const safeUrl = (valueArg: string | undefined): URL | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(valueArg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,445 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||
import type {
|
||||
IAmcrestCamera,
|
||||
IAmcrestClientCommand,
|
||||
IAmcrestEvent,
|
||||
IAmcrestSnapshot,
|
||||
IAmcrestSwitch,
|
||||
TAmcrestColorBw,
|
||||
TAmcrestPtzMovement,
|
||||
TAmcrestResolution,
|
||||
TAmcrestStreamSource,
|
||||
} from './amcrest.types.js';
|
||||
import { amcrestColorModes, amcrestPtzMovements, amcrestResolutionSubtype, amcrestSubtypeStream } from './amcrest.types.js';
|
||||
|
||||
const cameraStreamServices = new Set(['stream', 'stream_source', 'get_stream']);
|
||||
const cameraSnapshotServices = new Set(['snapshot', 'camera_image', 'camera_snapshot']);
|
||||
const serviceBooleanKeys = ['enabled', 'enable', 'on', 'state', 'value'];
|
||||
|
||||
export class AmcrestMapper {
|
||||
public static toDevices(snapshotArg: IAmcrestSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt },
|
||||
];
|
||||
|
||||
for (const camera of snapshotArg.cameras) {
|
||||
features.push({ id: `camera_${this.slug(camera.id)}`, capability: 'camera', name: camera.name || `Camera ${camera.channel}`, readable: true, writable: Boolean(camera.supportsPtz) });
|
||||
state.push({
|
||||
featureId: `camera_${this.slug(camera.id)}`,
|
||||
value: {
|
||||
snapshotUrl: camera.snapshotUrl || null,
|
||||
mjpegUrl: camera.mjpegUrl || null,
|
||||
rtspUrl: camera.rtspUrl || null,
|
||||
streamSource: camera.streamSource,
|
||||
isStreaming: camera.isStreaming ?? null,
|
||||
},
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
for (const sensor of snapshotArg.binarySensors) {
|
||||
features.push({ id: `binary_${this.slug(sensor.key)}`, capability: 'sensor', name: sensor.name, readable: true, writable: false });
|
||||
state.push({ featureId: `binary_${this.slug(sensor.key)}`, value: sensor.isOn, updatedAt });
|
||||
}
|
||||
for (const sensor of snapshotArg.sensors) {
|
||||
features.push({ id: `sensor_${this.slug(sensor.key)}`, capability: 'sensor', name: sensor.name, readable: true, writable: false, unit: sensor.unit });
|
||||
state.push({ featureId: `sensor_${this.slug(sensor.key)}`, value: this.deviceStateValue(sensor.value), updatedAt });
|
||||
}
|
||||
for (const switchArg of snapshotArg.switches) {
|
||||
features.push({ id: `switch_${this.slug(switchArg.key)}`, capability: 'switch', name: switchArg.name, readable: true, writable: true });
|
||||
state.push({ featureId: `switch_${this.slug(switchArg.key)}`, value: switchArg.isOn, updatedAt });
|
||||
}
|
||||
|
||||
return [{
|
||||
id: this.deviceId(snapshotArg),
|
||||
integrationDomain: 'amcrest',
|
||||
name: this.deviceName(snapshotArg),
|
||||
protocol: 'http',
|
||||
manufacturer: snapshotArg.deviceInfo.manufacturer || 'Amcrest',
|
||||
model: snapshotArg.deviceInfo.model,
|
||||
online: snapshotArg.connected,
|
||||
features,
|
||||
state,
|
||||
metadata: {
|
||||
serialNumber: snapshotArg.deviceInfo.serialNumber,
|
||||
macAddress: snapshotArg.deviceInfo.macAddress,
|
||||
firmwareVersion: snapshotArg.deviceInfo.firmwareVersion,
|
||||
host: snapshotArg.deviceInfo.host,
|
||||
port: snapshotArg.deviceInfo.port,
|
||||
protocol: snapshotArg.deviceInfo.protocol,
|
||||
rtspPort: snapshotArg.deviceInfo.rtspPort,
|
||||
cameraStreams: snapshotArg.cameras.map((cameraArg) => ({
|
||||
id: cameraArg.id,
|
||||
channel: cameraArg.channel,
|
||||
resolution: cameraArg.resolution,
|
||||
subtype: cameraArg.subtype,
|
||||
streamSource: cameraArg.streamSource,
|
||||
snapshotUrl: cameraArg.snapshotUrl,
|
||||
mjpegUrl: cameraArg.mjpegUrl,
|
||||
rtspUrl: cameraArg.rtspUrl,
|
||||
})),
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IAmcrestSnapshot): IIntegrationEntity[] {
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
const usedIds = new Map<string, number>();
|
||||
const deviceId = this.deviceId(snapshotArg);
|
||||
|
||||
for (const camera of snapshotArg.cameras) {
|
||||
entities.push(this.entity('camera' as TEntityPlatform, camera.name || `${this.deviceName(snapshotArg)} Camera`, deviceId, `amcrest_${this.uniqueBase(snapshotArg)}_camera_${this.slug(camera.id)}`, camera.available === false || !snapshotArg.connected ? 'unavailable' : camera.isStreaming === false ? 'off' : 'idle', usedIds, {
|
||||
cameraId: camera.id,
|
||||
channel: camera.channel,
|
||||
resolution: camera.resolution,
|
||||
subtype: camera.subtype,
|
||||
streamSource: camera.streamSource,
|
||||
streamSourceUrl: this.streamSourceUrl(camera),
|
||||
snapshotUrl: camera.snapshotUrl,
|
||||
stillImageUrl: camera.snapshotUrl,
|
||||
mjpegUrl: camera.mjpegUrl,
|
||||
rtspUrl: camera.rtspUrl,
|
||||
supportedFeatures: camera.supportsPtz ? ['stream', 'snapshot', 'ptz'] : ['stream', 'snapshot'],
|
||||
isStreaming: camera.isStreaming,
|
||||
isRecording: camera.isRecording,
|
||||
motionDetectionEnabled: camera.motionDetectionEnabled,
|
||||
audio: stateFromBoolean(camera.audioEnabled),
|
||||
motionRecording: stateFromBoolean(camera.motionRecordingEnabled),
|
||||
color_bw: camera.colorBw,
|
||||
serviceMappings: {
|
||||
snapshot: 'camera.snapshot',
|
||||
streamSource: 'camera.stream_source',
|
||||
ptzControl: 'amcrest.ptz_control',
|
||||
gotoPreset: 'amcrest.goto_preset',
|
||||
},
|
||||
...camera.attributes,
|
||||
}, snapshotArg.connected && camera.available !== false));
|
||||
}
|
||||
|
||||
for (const sensor of snapshotArg.binarySensors) {
|
||||
entities.push(this.entity('binary_sensor', sensor.name, deviceId, `amcrest_${this.uniqueBase(snapshotArg)}_${this.slug(sensor.key)}`, sensor.isOn ? 'on' : 'off', usedIds, {
|
||||
key: sensor.key,
|
||||
deviceClass: sensor.deviceClass,
|
||||
eventCodes: sensor.eventCodes,
|
||||
shouldPoll: sensor.shouldPoll,
|
||||
...sensor.attributes,
|
||||
}, sensor.key === 'online' || (snapshotArg.connected && sensor.available !== false)));
|
||||
}
|
||||
for (const sensor of snapshotArg.sensors) {
|
||||
entities.push(this.entity('sensor', sensor.name, deviceId, `amcrest_${this.uniqueBase(snapshotArg)}_${this.slug(sensor.key)}`, sensor.value ?? 'unknown', usedIds, {
|
||||
key: sensor.key,
|
||||
unit: sensor.unit,
|
||||
deviceClass: sensor.deviceClass,
|
||||
entityCategory: sensor.entityCategory,
|
||||
...sensor.attributes,
|
||||
}, snapshotArg.connected && sensor.available !== false));
|
||||
}
|
||||
for (const switchArg of snapshotArg.switches) {
|
||||
entities.push(this.entity('switch', switchArg.name, deviceId, `amcrest_${this.uniqueBase(snapshotArg)}_${this.slug(switchArg.key)}`, switchArg.isOn ? 'on' : 'off', usedIds, {
|
||||
key: switchArg.key,
|
||||
command: switchArg.command,
|
||||
entityCategory: switchArg.entityCategory,
|
||||
...switchArg.attributes,
|
||||
}, snapshotArg.connected && switchArg.available !== false));
|
||||
}
|
||||
for (const event of snapshotArg.events) {
|
||||
entities.push(this.entity('event' as TEntityPlatform, event.name || event.id, deviceId, `amcrest_${this.uniqueBase(snapshotArg)}_event_${this.slug(event.id)}`, event.state || (event.isOn ? 'on' : 'off'), usedIds, {
|
||||
eventId: event.id,
|
||||
code: event.code,
|
||||
updatedAt: event.updatedAt,
|
||||
payload: event.payload,
|
||||
}, true));
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static commandForService(snapshotArg: IAmcrestSnapshot, requestArg: IServiceCallRequest): IAmcrestClientCommand | undefined {
|
||||
if (requestArg.domain === 'camera' && cameraStreamServices.has(requestArg.service)) {
|
||||
const camera = this.findCamera(snapshotArg, requestArg);
|
||||
return {
|
||||
type: 'stream_source',
|
||||
service: requestArg.service,
|
||||
target: requestArg.target,
|
||||
data: requestArg.data,
|
||||
cameraId: camera?.id,
|
||||
channel: camera?.channel,
|
||||
resolution: this.resolutionValue(requestArg.data?.resolution) || camera?.resolution,
|
||||
streamSource: this.streamSourceValue(requestArg.data?.stream_source ?? requestArg.data?.streamSource) || camera?.streamSource,
|
||||
};
|
||||
}
|
||||
if (requestArg.domain === 'camera' && cameraSnapshotServices.has(requestArg.service)) {
|
||||
const camera = this.findCamera(snapshotArg, requestArg);
|
||||
return {
|
||||
type: 'snapshot_image',
|
||||
service: requestArg.service,
|
||||
target: requestArg.target,
|
||||
data: requestArg.data,
|
||||
cameraId: camera?.id,
|
||||
channel: camera?.channel,
|
||||
filename: this.stringValue(requestArg.data?.filename),
|
||||
httpCommands: camera ? [{ label: 'snapshot', method: 'GET', path: `/cgi-bin/snapshot.cgi?channel=${camera.channel}`, expect: 'image' }] : undefined,
|
||||
};
|
||||
}
|
||||
if (requestArg.domain === 'camera') {
|
||||
return this.cameraCommand(snapshotArg, requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'switch' && ['turn_on', 'turn_off', 'toggle'].includes(requestArg.service)) {
|
||||
const switchEntity = this.findSwitch(snapshotArg, requestArg);
|
||||
if (!switchEntity) {
|
||||
return undefined;
|
||||
}
|
||||
const enabled = requestArg.service === 'turn_on' ? true : requestArg.service === 'turn_off' ? false : !switchEntity.isOn;
|
||||
return {
|
||||
type: 'set_privacy_mode',
|
||||
service: requestArg.service,
|
||||
target: requestArg.target,
|
||||
data: requestArg.data,
|
||||
enabled,
|
||||
httpCommands: this.booleanHttpCommands('privacy_mode', snapshotArg, enabled),
|
||||
};
|
||||
}
|
||||
if (requestArg.domain === 'amcrest') {
|
||||
return this.amcrestCommand(snapshotArg, requestArg);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IAmcrestSnapshot): string {
|
||||
return `amcrest.device.${this.uniqueBase(snapshotArg)}`;
|
||||
}
|
||||
|
||||
private static cameraCommand(snapshotArg: IAmcrestSnapshot, requestArg: IServiceCallRequest): IAmcrestClientCommand | undefined {
|
||||
const camera = this.findCamera(snapshotArg, requestArg);
|
||||
if (requestArg.service === 'turn_on' || requestArg.service === 'turn_off') {
|
||||
const enabled = requestArg.service === 'turn_on';
|
||||
return { type: 'set_video', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('video', snapshotArg, enabled, camera) };
|
||||
}
|
||||
if (requestArg.service === 'enable_motion_detection' || requestArg.service === 'disable_motion_detection') {
|
||||
const enabled = requestArg.service === 'enable_motion_detection';
|
||||
return { type: 'set_motion_detection', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('motion_detection', snapshotArg, enabled, camera) };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static amcrestCommand(snapshotArg: IAmcrestSnapshot, requestArg: IServiceCallRequest): IAmcrestClientCommand | undefined {
|
||||
if (requestArg.service === 'refresh') {
|
||||
return { type: 'refresh', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (cameraStreamServices.has(requestArg.service) || cameraSnapshotServices.has(requestArg.service)) {
|
||||
return this.commandForService(snapshotArg, { ...requestArg, domain: 'camera' });
|
||||
}
|
||||
const camera = this.findCamera(snapshotArg, requestArg);
|
||||
if (requestArg.service === 'enable_recording' || requestArg.service === 'disable_recording') {
|
||||
const enabled = requestArg.service === 'enable_recording';
|
||||
return { type: 'set_recording', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled };
|
||||
}
|
||||
if (requestArg.service === 'enable_audio' || requestArg.service === 'disable_audio') {
|
||||
const enabled = requestArg.service === 'enable_audio';
|
||||
return { type: 'set_audio', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('audio', snapshotArg, enabled, camera) };
|
||||
}
|
||||
if (requestArg.service === 'enable_motion_recording' || requestArg.service === 'disable_motion_recording') {
|
||||
const enabled = requestArg.service === 'enable_motion_recording';
|
||||
return { type: 'set_motion_recording', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('motion_recording', snapshotArg, enabled, camera) };
|
||||
}
|
||||
if (requestArg.service === 'set_privacy_mode') {
|
||||
const enabled = this.booleanFromData(requestArg.data);
|
||||
return enabled === undefined ? undefined : { type: 'set_privacy_mode', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('privacy_mode', snapshotArg, enabled, camera) };
|
||||
}
|
||||
if (requestArg.service === 'set_video') {
|
||||
const enabled = this.booleanFromData(requestArg.data);
|
||||
return enabled === undefined ? undefined : { type: 'set_video', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('video', snapshotArg, enabled, camera) };
|
||||
}
|
||||
if (requestArg.service === 'set_audio') {
|
||||
const enabled = this.booleanFromData(requestArg.data);
|
||||
return enabled === undefined ? undefined : { type: 'set_audio', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('audio', snapshotArg, enabled, camera) };
|
||||
}
|
||||
if (requestArg.service === 'set_motion_detection') {
|
||||
const enabled = this.booleanFromData(requestArg.data);
|
||||
return enabled === undefined ? undefined : { type: 'set_motion_detection', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('motion_detection', snapshotArg, enabled, camera) };
|
||||
}
|
||||
if (requestArg.service === 'goto_preset') {
|
||||
const preset = this.numberValue(requestArg.data?.preset);
|
||||
return preset === undefined ? undefined : { type: 'goto_preset', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, preset, httpCommands: [{ label: 'goto_preset', method: 'GET', path: `/cgi-bin/ptz.cgi?action=start&channel=${camera?.channel ?? 0}&code=GotoPreset&arg1=0&arg2=${Math.round(preset)}&arg3=0`, expect: 'ok' }] };
|
||||
}
|
||||
if (requestArg.service === 'ptz_control') {
|
||||
const movement = this.ptzMovement(requestArg.data?.movement);
|
||||
if (!movement) {
|
||||
return undefined;
|
||||
}
|
||||
return { type: 'ptz_control', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, movement, travelTime: this.numberValue(requestArg.data?.travel_time ?? requestArg.data?.travelTime) ?? 0.2 };
|
||||
}
|
||||
if (requestArg.service === 'set_color_bw') {
|
||||
const colorBw = this.colorBw(requestArg.data?.color_bw ?? requestArg.data?.colorBw);
|
||||
return colorBw ? { type: 'set_color_bw', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, colorBw } : undefined;
|
||||
}
|
||||
if (requestArg.service === 'start_tour' || requestArg.service === 'stop_tour') {
|
||||
return { type: requestArg.service, service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel } as IAmcrestClientCommand;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static booleanHttpCommands(kindArg: 'privacy_mode' | 'video' | 'audio' | 'motion_detection' | 'motion_recording', snapshotArg: IAmcrestSnapshot, enabledArg: boolean, cameraArg?: IAmcrestCamera) {
|
||||
const camera = cameraArg || snapshotArg.cameras[0];
|
||||
const channel = camera?.channel ?? 0;
|
||||
const subtype = camera?.subtype ?? amcrestResolutionSubtype.high;
|
||||
const format = `${amcrestSubtypeStream[subtype]}Format`;
|
||||
const value = String(enabledArg).toLowerCase();
|
||||
const field = kindArg === 'privacy_mode'
|
||||
? `LeLensMask[${channel}].Enable`
|
||||
: kindArg === 'video'
|
||||
? `Encode[${channel}].${format}[0].VideoEnable`
|
||||
: kindArg === 'audio'
|
||||
? `Encode[${channel}].${format}[0].AudioEnable`
|
||||
: kindArg === 'motion_detection'
|
||||
? `MotionDetect[${channel}].Enable`
|
||||
: `MotionDetect[${channel}].EventHandler.RecordEnable`;
|
||||
return [{ label: kindArg, method: 'GET' as const, path: `/cgi-bin/configManager.cgi?action=setConfig&${field}=${value}`, expect: 'ok' as const }];
|
||||
}
|
||||
|
||||
private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map<string, number>, attributesArg: Record<string, unknown>, availableArg: boolean): IIntegrationEntity {
|
||||
const baseId = `${String(platformArg)}.${this.slug(nameArg) || this.slug(uniqueIdArg)}`;
|
||||
const seen = usedIdsArg.get(baseId) || 0;
|
||||
usedIdsArg.set(baseId, seen + 1);
|
||||
return {
|
||||
id: seen ? `${baseId}_${seen + 1}` : baseId,
|
||||
uniqueId: uniqueIdArg,
|
||||
integrationDomain: 'amcrest',
|
||||
deviceId: deviceIdArg,
|
||||
platform: platformArg,
|
||||
name: nameArg,
|
||||
state: stateArg,
|
||||
attributes: this.cleanAttributes(attributesArg),
|
||||
available: availableArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static findSwitch(snapshotArg: IAmcrestSnapshot, requestArg: IServiceCallRequest): IAmcrestSwitch | undefined {
|
||||
const target = requestArg.target.entityId || requestArg.target.deviceId || this.stringValue(requestArg.data?.entity_id ?? requestArg.data?.entityId ?? requestArg.data?.key);
|
||||
if (!target) {
|
||||
return snapshotArg.switches[0];
|
||||
}
|
||||
const entities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === 'switch');
|
||||
const entity = entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.key === target);
|
||||
const key = this.stringValue(entity?.attributes?.key) || target;
|
||||
return snapshotArg.switches.find((switchArg) => switchArg.key === key || switchArg.name === target || this.deviceId(snapshotArg) === target);
|
||||
}
|
||||
|
||||
private static findCamera(snapshotArg: IAmcrestSnapshot, requestArg: IServiceCallRequest): IAmcrestCamera | undefined {
|
||||
const target = requestArg.target.entityId || this.stringValue(requestArg.data?.cameraId ?? requestArg.data?.camera_id ?? requestArg.data?.camera ?? requestArg.data?.channel);
|
||||
if (!target) {
|
||||
return snapshotArg.cameras[0];
|
||||
}
|
||||
const entities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === ('camera' as TEntityPlatform));
|
||||
const entity = entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.cameraId === target || entityArg.attributes?.channel === target);
|
||||
const cameraId = this.stringValue(entity?.attributes?.cameraId) || target;
|
||||
return snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.channel) === cameraId) || snapshotArg.cameras[0];
|
||||
}
|
||||
|
||||
private static streamSourceUrl(cameraArg: IAmcrestCamera): string | undefined {
|
||||
if (cameraArg.streamSource === 'rtsp') {
|
||||
return cameraArg.rtspUrl;
|
||||
}
|
||||
if (cameraArg.streamSource === 'mjpeg') {
|
||||
return cameraArg.mjpegUrl;
|
||||
}
|
||||
return cameraArg.snapshotUrl;
|
||||
}
|
||||
|
||||
private static deviceName(snapshotArg: IAmcrestSnapshot): string {
|
||||
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.host || 'Amcrest Camera';
|
||||
}
|
||||
|
||||
private static uniqueBase(snapshotArg: IAmcrestSnapshot): string {
|
||||
return this.slug(snapshotArg.deviceInfo.macAddress || snapshotArg.deviceInfo.serialNumber || snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.host || this.deviceName(snapshotArg));
|
||||
}
|
||||
|
||||
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
|
||||
}
|
||||
|
||||
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||
if (valueArg === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (valueArg === null || typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) {
|
||||
return valueArg as Record<string, unknown>;
|
||||
}
|
||||
return String(valueArg);
|
||||
}
|
||||
|
||||
private static booleanFromData(dataArg: Record<string, unknown> | undefined): boolean | undefined {
|
||||
for (const key of serviceBooleanKeys) {
|
||||
const value = this.booleanValue(dataArg?.[key]);
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static booleanValue(valueArg: unknown): boolean | undefined {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
if (['on', 'true', 'yes', '1'].includes(valueArg.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
if (['off', 'false', 'no', '0'].includes(valueArg.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const value = Number(valueArg);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' ? String(valueArg) : undefined;
|
||||
}
|
||||
|
||||
private static resolutionValue(valueArg: unknown): TAmcrestResolution | undefined {
|
||||
const value = this.stringValue(valueArg);
|
||||
return value === 'high' || value === 'low' ? value : undefined;
|
||||
}
|
||||
|
||||
private static streamSourceValue(valueArg: unknown): TAmcrestStreamSource | undefined {
|
||||
const value = this.stringValue(valueArg);
|
||||
return value === 'snapshot' || value === 'mjpeg' || value === 'rtsp' ? value : undefined;
|
||||
}
|
||||
|
||||
private static colorBw(valueArg: unknown): TAmcrestColorBw | undefined {
|
||||
const value = this.stringValue(valueArg)?.toLowerCase();
|
||||
return value && amcrestColorModes.includes(value as TAmcrestColorBw) ? value as TAmcrestColorBw : undefined;
|
||||
}
|
||||
|
||||
private static ptzMovement(valueArg: unknown): TAmcrestPtzMovement | undefined {
|
||||
const value = this.stringValue(valueArg)?.toLowerCase();
|
||||
return value && amcrestPtzMovements.includes(value as TAmcrestPtzMovement) ? value as TAmcrestPtzMovement : undefined;
|
||||
}
|
||||
|
||||
private static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'amcrest';
|
||||
}
|
||||
}
|
||||
|
||||
const stateFromBoolean = (valueArg: boolean | undefined): string | undefined => {
|
||||
return valueArg === undefined ? undefined : valueArg ? 'on' : 'off';
|
||||
};
|
||||
@@ -1,4 +1,303 @@
|
||||
export interface IHomeAssistantAmcrestConfig {
|
||||
// TODO: replace with the TypeScript-native config for amcrest.
|
||||
[key: string]: unknown;
|
||||
export const amcrestDefaultPort = 80;
|
||||
export const amcrestDefaultRtspPort = 554;
|
||||
export const amcrestDefaultTimeoutMs = 10000;
|
||||
export const amcrestDefaultSnapshotTimeoutMs = 20000;
|
||||
|
||||
export const amcrestResolutionSubtype = {
|
||||
high: 0,
|
||||
low: 1,
|
||||
} as const;
|
||||
|
||||
export const amcrestSubtypeStream = {
|
||||
0: 'Main',
|
||||
1: 'Extra',
|
||||
} as const;
|
||||
|
||||
export const amcrestColorModes = ['color', 'auto', 'bw'] as const;
|
||||
export const amcrestStreamSources = ['snapshot', 'mjpeg', 'rtsp'] as const;
|
||||
export const amcrestPtzMovements = [
|
||||
'zoom_out',
|
||||
'zoom_in',
|
||||
'right',
|
||||
'left',
|
||||
'up',
|
||||
'down',
|
||||
'right_down',
|
||||
'right_up',
|
||||
'left_down',
|
||||
'left_up',
|
||||
] as const;
|
||||
|
||||
export type TAmcrestProtocol = 'http' | 'https';
|
||||
export type TAmcrestAuthScheme = 'auto' | 'basic' | 'digest';
|
||||
export type TAmcrestResolution = keyof typeof amcrestResolutionSubtype;
|
||||
export type TAmcrestStreamSource = typeof amcrestStreamSources[number];
|
||||
export type TAmcrestColorBw = typeof amcrestColorModes[number];
|
||||
export type TAmcrestPtzMovement = typeof amcrestPtzMovements[number];
|
||||
export type TAmcrestSwitchCommand = 'privacy_mode';
|
||||
export type TAmcrestHttpMethod = 'GET';
|
||||
export type TAmcrestCommandType =
|
||||
| 'refresh'
|
||||
| 'stream_source'
|
||||
| 'snapshot_image'
|
||||
| 'set_privacy_mode'
|
||||
| 'set_video'
|
||||
| 'set_recording'
|
||||
| 'set_audio'
|
||||
| 'set_motion_detection'
|
||||
| 'set_motion_recording'
|
||||
| 'set_color_bw'
|
||||
| 'goto_preset'
|
||||
| 'ptz_control'
|
||||
| 'start_tour'
|
||||
| 'stop_tour';
|
||||
|
||||
export interface IAmcrestConfig {
|
||||
protocol?: TAmcrestProtocol;
|
||||
host?: string;
|
||||
port?: number;
|
||||
url?: string;
|
||||
rtspPort?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
authScheme?: TAmcrestAuthScheme;
|
||||
timeoutMs?: number;
|
||||
snapshotTimeoutMs?: number;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
channel?: number;
|
||||
resolution?: TAmcrestResolution;
|
||||
streamSource?: TAmcrestStreamSource;
|
||||
controlLight?: boolean;
|
||||
ffmpegArguments?: string;
|
||||
supportsPtz?: boolean;
|
||||
connected?: boolean;
|
||||
deviceInfo?: IAmcrestDeviceInfo;
|
||||
cameras?: IAmcrestCamera[];
|
||||
sensors?: IAmcrestSensor[];
|
||||
binarySensors?: IAmcrestBinarySensor[];
|
||||
switches?: IAmcrestSwitch[];
|
||||
events?: IAmcrestEvent[];
|
||||
enabledSensors?: string[];
|
||||
enabledBinarySensors?: string[];
|
||||
enabledSwitches?: string[];
|
||||
currentSettings?: Record<string, unknown>;
|
||||
snapshot?: IAmcrestSnapshot;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantAmcrestConfig extends IAmcrestConfig {}
|
||||
|
||||
export interface IAmcrestDeviceInfo {
|
||||
id?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
macAddress?: string;
|
||||
firmwareVersion?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: TAmcrestProtocol;
|
||||
rtspPort?: number;
|
||||
url?: string;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
export interface IAmcrestCamera {
|
||||
id: string;
|
||||
name?: string;
|
||||
channel: number;
|
||||
resolution: TAmcrestResolution;
|
||||
subtype: 0 | 1;
|
||||
streamSource: TAmcrestStreamSource;
|
||||
snapshotUrl?: string;
|
||||
mjpegUrl?: string;
|
||||
rtspUrl?: string;
|
||||
available?: boolean;
|
||||
isStreaming?: boolean;
|
||||
isRecording?: boolean;
|
||||
motionDetectionEnabled?: boolean;
|
||||
audioEnabled?: boolean;
|
||||
motionRecordingEnabled?: boolean;
|
||||
colorBw?: TAmcrestColorBw;
|
||||
supportsPtz?: boolean;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAmcrestSensor<TValue = unknown> {
|
||||
key: string;
|
||||
name: string;
|
||||
value: TValue;
|
||||
unit?: string;
|
||||
deviceClass?: string;
|
||||
entityCategory?: string;
|
||||
available?: boolean;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAmcrestBinarySensor {
|
||||
key: string;
|
||||
name: string;
|
||||
isOn: boolean;
|
||||
deviceClass?: string;
|
||||
eventCodes?: string[];
|
||||
shouldPoll?: boolean;
|
||||
available?: boolean;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAmcrestSwitch {
|
||||
key: string;
|
||||
name: string;
|
||||
isOn: boolean;
|
||||
command: TAmcrestSwitchCommand;
|
||||
entityCategory?: string;
|
||||
available?: boolean;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAmcrestEvent {
|
||||
id: string;
|
||||
name?: string;
|
||||
code?: string;
|
||||
state?: string;
|
||||
isOn?: boolean;
|
||||
updatedAt?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAmcrestSnapshot {
|
||||
deviceInfo: IAmcrestDeviceInfo;
|
||||
cameras: IAmcrestCamera[];
|
||||
sensors: IAmcrestSensor[];
|
||||
binarySensors: IAmcrestBinarySensor[];
|
||||
switches: IAmcrestSwitch[];
|
||||
events: IAmcrestEvent[];
|
||||
currentSettings: Record<string, unknown>;
|
||||
connected: boolean;
|
||||
updatedAt?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAmcrestHttpCommand {
|
||||
label: string;
|
||||
method: TAmcrestHttpMethod;
|
||||
path: string;
|
||||
expect?: 'ok' | 'image' | 'text';
|
||||
}
|
||||
|
||||
export interface IAmcrestCommandResponse {
|
||||
ok: boolean;
|
||||
label: string;
|
||||
method: TAmcrestHttpMethod;
|
||||
path: string;
|
||||
status: number;
|
||||
responseText?: string;
|
||||
}
|
||||
|
||||
export interface IAmcrestClientCommand {
|
||||
type: TAmcrestCommandType;
|
||||
service: string;
|
||||
target?: {
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
data?: Record<string, unknown>;
|
||||
cameraId?: string;
|
||||
channel?: number;
|
||||
resolution?: TAmcrestResolution;
|
||||
streamSource?: TAmcrestStreamSource;
|
||||
filename?: string;
|
||||
enabled?: boolean;
|
||||
preset?: number;
|
||||
colorBw?: TAmcrestColorBw;
|
||||
movement?: TAmcrestPtzMovement;
|
||||
travelTime?: number;
|
||||
httpCommands?: IAmcrestHttpCommand[];
|
||||
}
|
||||
|
||||
export interface IAmcrestSnapshotImage {
|
||||
contentType: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface IAmcrestSensorDescription {
|
||||
key: string;
|
||||
name: string;
|
||||
unit?: string;
|
||||
deviceClass?: string;
|
||||
entityCategory?: string;
|
||||
}
|
||||
|
||||
export interface IAmcrestBinarySensorDescription {
|
||||
key: string;
|
||||
name: string;
|
||||
deviceClass?: string;
|
||||
eventCodes?: string[];
|
||||
shouldPoll?: boolean;
|
||||
}
|
||||
|
||||
export interface IAmcrestSwitchDescription {
|
||||
key: string;
|
||||
name: string;
|
||||
command: TAmcrestSwitchCommand;
|
||||
entityCategory?: string;
|
||||
}
|
||||
|
||||
export interface IAmcrestManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
url?: string;
|
||||
protocol?: TAmcrestProtocol;
|
||||
username?: string;
|
||||
password?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
macAddress?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAmcrestMdnsRecord {
|
||||
type?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
addresses?: string[];
|
||||
hostname?: string;
|
||||
txt?: Record<string, string | undefined>;
|
||||
properties?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface IAmcrestSsdpRecord {
|
||||
manufacturer?: string;
|
||||
server?: string;
|
||||
st?: string;
|
||||
usn?: string;
|
||||
location?: string;
|
||||
upnp?: Record<string, string | undefined>;
|
||||
headers?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export const amcrestSensorDescriptions: IAmcrestSensorDescription[] = [
|
||||
{ key: 'ptz_preset', name: 'PTZ Preset', entityCategory: 'diagnostic' },
|
||||
{ key: 'sdcard', name: 'SD Used', unit: '%', entityCategory: 'diagnostic' },
|
||||
];
|
||||
|
||||
export const amcrestBinarySensorDescriptions: IAmcrestBinarySensorDescription[] = [
|
||||
{ key: 'audio_detected', name: 'Audio Detected', deviceClass: 'sound', eventCodes: ['AudioMutation', 'AudioIntensity'] },
|
||||
{ key: 'audio_detected_polled', name: 'Audio Detected', deviceClass: 'sound', eventCodes: ['AudioMutation', 'AudioIntensity'], shouldPoll: true },
|
||||
{ key: 'crossline_detected', name: 'CrossLine Detected', deviceClass: 'motion', eventCodes: ['CrossLineDetection'] },
|
||||
{ key: 'crossline_detected_polled', name: 'CrossLine Detected', deviceClass: 'motion', eventCodes: ['CrossLineDetection'], shouldPoll: true },
|
||||
{ key: 'motion_detected', name: 'Motion Detected', deviceClass: 'motion', eventCodes: ['VideoMotion'] },
|
||||
{ key: 'motion_detected_polled', name: 'Motion Detected', deviceClass: 'motion', eventCodes: ['VideoMotion'], shouldPoll: true },
|
||||
{ key: 'online', name: 'Online', deviceClass: 'connectivity', shouldPoll: true },
|
||||
];
|
||||
|
||||
export const amcrestSwitchDescriptions: IAmcrestSwitchDescription[] = [
|
||||
{ key: 'privacy_mode', name: 'Privacy Mode', command: 'privacy_mode', entityCategory: 'config' },
|
||||
];
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './amcrest.classes.client.js';
|
||||
export * from './amcrest.classes.configflow.js';
|
||||
export * from './amcrest.classes.integration.js';
|
||||
export * from './amcrest.discovery.js';
|
||||
export * from './amcrest.mapper.js';
|
||||
export * from './amcrest.types.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
androidtvRemoteApiPort,
|
||||
androidtvRemoteKeyAliases,
|
||||
androidtvRemoteKnownApps,
|
||||
androidtvRemotePairPort,
|
||||
} from './androidtv_remote.constants.js';
|
||||
import type {
|
||||
IAndroidtvRemoteApp,
|
||||
IAndroidtvRemoteCommand,
|
||||
IAndroidtvRemoteCommandContext,
|
||||
IAndroidtvRemoteConfig,
|
||||
IAndroidtvRemoteConfiguredApp,
|
||||
IAndroidtvRemoteDeviceInfo,
|
||||
IAndroidtvRemoteDeviceState,
|
||||
IAndroidtvRemoteKeyPress,
|
||||
IAndroidtvRemoteSnapshot,
|
||||
IAndroidtvRemoteVolumeInfo,
|
||||
TAndroidtvRemoteCommandDirection,
|
||||
TAndroidtvRemoteCommandExecutor,
|
||||
TAndroidtvRemoteCommandReason,
|
||||
TAndroidtvRemoteKeyCode,
|
||||
} from './androidtv_remote.types.js';
|
||||
|
||||
export class AndroidtvRemoteUnsupportedProtocolError extends Error {
|
||||
constructor(commandArg: IAndroidtvRemoteCommand) {
|
||||
super(`Android TV Remote protocol action "${commandArg.action}" requires an injected executor. This TypeScript port does not implement pairing or live androidtvremote2 transport.`);
|
||||
this.name = 'AndroidtvRemoteUnsupportedProtocolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AndroidtvRemoteClient {
|
||||
private readonly snapshot?: IAndroidtvRemoteSnapshot;
|
||||
|
||||
constructor(private readonly config: IAndroidtvRemoteConfig) {
|
||||
this.snapshot = config.snapshot ? this.cloneSnapshot(config.snapshot) : undefined;
|
||||
}
|
||||
|
||||
public async getSnapshot(): Promise<IAndroidtvRemoteSnapshot> {
|
||||
return this.normalizeSnapshot(this.snapshot || this.snapshotFromManualConfig());
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
await this.execute({ action: 'connect', reason: 'connect' });
|
||||
}
|
||||
|
||||
public async startPairing(): Promise<void> {
|
||||
await this.execute({ action: 'start_pairing', reason: 'start_pairing' });
|
||||
}
|
||||
|
||||
public async finishPairing(pinArg: string): Promise<void> {
|
||||
await this.execute({ action: 'finish_pairing', reason: 'finish_pairing', pin: pinArg });
|
||||
}
|
||||
|
||||
public async turnOn(): Promise<void> {
|
||||
await this.sendKeyCommand('POWER', 'SHORT', 'turn_on');
|
||||
}
|
||||
|
||||
public async turnOff(): Promise<void> {
|
||||
await this.sendKeyCommand('POWER', 'SHORT', 'turn_off');
|
||||
}
|
||||
|
||||
public async volumeUp(): Promise<void> {
|
||||
await this.sendKeyCommand('VOLUME_UP', 'SHORT', 'volume_up');
|
||||
}
|
||||
|
||||
public async volumeDown(): Promise<void> {
|
||||
await this.sendKeyCommand('VOLUME_DOWN', 'SHORT', 'volume_down');
|
||||
}
|
||||
|
||||
public async muteVolume(mutedArg: boolean): Promise<void> {
|
||||
await this.sendKeyCommand('VOLUME_MUTE', 'SHORT', 'volume_mute', { muted: mutedArg });
|
||||
}
|
||||
|
||||
public async setVolumeLevel(volumeLevelArg: number): Promise<void> {
|
||||
const volumeLevel = Math.max(0, Math.min(1, volumeLevelArg));
|
||||
await this.execute({ action: 'volume_set', reason: 'volume_set', volumeLevel });
|
||||
}
|
||||
|
||||
public async mediaPlay(): Promise<void> {
|
||||
await this.sendKeyCommand('MEDIA_PLAY', 'SHORT', 'media_play');
|
||||
}
|
||||
|
||||
public async mediaPause(): Promise<void> {
|
||||
await this.sendKeyCommand('MEDIA_PAUSE', 'SHORT', 'media_pause');
|
||||
}
|
||||
|
||||
public async mediaPlayPause(): Promise<void> {
|
||||
await this.sendKeyCommand('MEDIA_PLAY_PAUSE', 'SHORT', 'media_play_pause');
|
||||
}
|
||||
|
||||
public async mediaStop(): Promise<void> {
|
||||
await this.sendKeyCommand('MEDIA_STOP', 'SHORT', 'media_stop');
|
||||
}
|
||||
|
||||
public async mediaPreviousTrack(): Promise<void> {
|
||||
await this.sendKeyCommand('MEDIA_PREVIOUS', 'SHORT', 'media_previous_track');
|
||||
}
|
||||
|
||||
public async mediaNextTrack(): Promise<void> {
|
||||
await this.sendKeyCommand('MEDIA_NEXT', 'SHORT', 'media_next_track');
|
||||
}
|
||||
|
||||
public async playChannel(channelArg: string): Promise<void> {
|
||||
if (!/^\d+$/.test(channelArg)) {
|
||||
throw new Error(`Android TV Remote channel media_id must be numeric: ${channelArg}`);
|
||||
}
|
||||
await this.sendCommand(channelArg.split(''), { reason: 'play_channel' });
|
||||
}
|
||||
|
||||
public async launchApp(appLinkOrAppIdArg: string, reasonArg: TAndroidtvRemoteCommandReason = 'launch_app'): Promise<void> {
|
||||
const app = await this.appForActivity(appLinkOrAppIdArg);
|
||||
const appId = app?.id || (this.hasUrlScheme(appLinkOrAppIdArg) ? undefined : appLinkOrAppIdArg);
|
||||
const appLink = app?.link || (this.hasUrlScheme(appLinkOrAppIdArg) ? appLinkOrAppIdArg : `market://launch?id=${appLinkOrAppIdArg}`);
|
||||
await this.execute({
|
||||
action: 'launch_app',
|
||||
reason: reasonArg,
|
||||
appId,
|
||||
appLink,
|
||||
appName: app?.name || (appId ? androidtvRemoteKnownApps[appId] : undefined),
|
||||
});
|
||||
}
|
||||
|
||||
public async selectActivity(activityArg: string): Promise<void> {
|
||||
const app = await this.appForActivity(activityArg);
|
||||
await this.launchApp(app?.id || activityArg, 'select_activity');
|
||||
}
|
||||
|
||||
public async sendText(textArg: string): Promise<void> {
|
||||
await this.execute({ action: 'send_text', reason: 'send_text', text: textArg });
|
||||
}
|
||||
|
||||
public async sendKeyCommand(
|
||||
keyCodeArg: TAndroidtvRemoteKeyCode | string,
|
||||
directionArg: TAndroidtvRemoteCommandDirection = 'SHORT',
|
||||
reasonArg: TAndroidtvRemoteCommandReason = 'remote_send_command',
|
||||
extraArg: Partial<IAndroidtvRemoteCommand> = {}
|
||||
): Promise<void> {
|
||||
await this.execute({
|
||||
action: 'key_command',
|
||||
reason: reasonArg,
|
||||
keyCode: this.normalizeKeyCode(keyCodeArg),
|
||||
direction: directionArg,
|
||||
...extraArg,
|
||||
});
|
||||
}
|
||||
|
||||
public async sendCommand(
|
||||
commandsArg: Array<TAndroidtvRemoteKeyCode | string>,
|
||||
optionsArg: {
|
||||
repeats?: number;
|
||||
delaySecs?: number;
|
||||
holdSecs?: number;
|
||||
reason?: TAndroidtvRemoteCommandReason;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const keys: IAndroidtvRemoteKeyPress[] = commandsArg.flatMap((keyArg): IAndroidtvRemoteKeyPress[] => {
|
||||
const keyCode = this.normalizeKeyCode(keyArg);
|
||||
if (optionsArg.holdSecs) {
|
||||
return [
|
||||
{ keyCode, direction: 'START_LONG' },
|
||||
{ keyCode, direction: 'END_LONG' },
|
||||
];
|
||||
}
|
||||
return [{ keyCode, direction: 'SHORT' }];
|
||||
});
|
||||
await this.execute({
|
||||
action: 'remote_send_command',
|
||||
reason: optionsArg.reason || 'remote_send_command',
|
||||
keys,
|
||||
repeats: this.repeats(optionsArg.repeats),
|
||||
delaySecs: optionsArg.delaySecs,
|
||||
holdSecs: optionsArg.holdSecs,
|
||||
});
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async execute(commandArg: IAndroidtvRemoteCommand): Promise<void> {
|
||||
const executor = this.config.executor;
|
||||
if (!executor) {
|
||||
throw new AndroidtvRemoteUnsupportedProtocolError(commandArg);
|
||||
}
|
||||
const context: IAndroidtvRemoteCommandContext = {
|
||||
config: this.config,
|
||||
snapshot: await this.getSnapshot(),
|
||||
};
|
||||
if (typeof executor === 'function') {
|
||||
await executor(commandArg, context);
|
||||
return;
|
||||
}
|
||||
await executor.execute(commandArg, context);
|
||||
}
|
||||
|
||||
private snapshotFromManualConfig(): IAndroidtvRemoteSnapshot {
|
||||
const deviceInfo: IAndroidtvRemoteDeviceInfo = {
|
||||
...this.config.deviceInfo,
|
||||
host: this.config.deviceInfo?.host || this.config.host,
|
||||
apiPort: this.config.deviceInfo?.apiPort || this.config.apiPort || androidtvRemoteApiPort,
|
||||
pairPort: this.config.deviceInfo?.pairPort || this.config.pairPort || androidtvRemotePairPort,
|
||||
name: this.config.deviceInfo?.name || this.config.deviceName || this.config.host || 'Android TV Remote',
|
||||
macAddress: this.config.deviceInfo?.macAddress || this.config.macAddress,
|
||||
manufacturer: this.config.deviceInfo?.manufacturer || this.config.manufacturer,
|
||||
model: this.config.deviceInfo?.model || this.config.model,
|
||||
};
|
||||
const state: IAndroidtvRemoteDeviceState = {
|
||||
mediaState: 'unknown',
|
||||
available: false,
|
||||
...this.config.state,
|
||||
volumeInfo: this.config.state?.volumeInfo || this.config.volumeInfo,
|
||||
};
|
||||
return {
|
||||
deviceInfo,
|
||||
state,
|
||||
apps: this.normalizeApps(this.config.apps),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IAndroidtvRemoteSnapshot): IAndroidtvRemoteSnapshot {
|
||||
const deviceInfo: IAndroidtvRemoteDeviceInfo = {
|
||||
...snapshotArg.deviceInfo,
|
||||
host: snapshotArg.deviceInfo.host || this.config.host,
|
||||
apiPort: snapshotArg.deviceInfo.apiPort || this.config.apiPort || androidtvRemoteApiPort,
|
||||
pairPort: snapshotArg.deviceInfo.pairPort || this.config.pairPort || androidtvRemotePairPort,
|
||||
macAddress: snapshotArg.deviceInfo.macAddress || this.config.macAddress,
|
||||
};
|
||||
if (!deviceInfo.name) {
|
||||
deviceInfo.name = this.config.deviceName || deviceInfo.host || 'Android TV Remote';
|
||||
}
|
||||
const apps = this.normalizeApps(snapshotArg.apps.length ? snapshotArg.apps : this.config.apps);
|
||||
const volumeInfo = this.normalizeVolumeInfo(snapshotArg.state.volumeInfo || this.config.volumeInfo);
|
||||
const state: IAndroidtvRemoteDeviceState = {
|
||||
...snapshotArg.state,
|
||||
volumeInfo,
|
||||
};
|
||||
if (state.available === undefined) {
|
||||
state.available = state.isOn !== undefined || Boolean(state.currentApp || volumeInfo);
|
||||
}
|
||||
if (!state.currentAppName && state.currentApp) {
|
||||
state.currentAppName = this.appName(apps, state.currentApp);
|
||||
}
|
||||
if (!state.currentActivity) {
|
||||
state.currentActivity = state.currentAppName || state.currentApp;
|
||||
}
|
||||
if (state.isVolumeMuted === undefined && volumeInfo?.muted !== undefined) {
|
||||
state.isVolumeMuted = volumeInfo.muted;
|
||||
}
|
||||
if (state.volumeLevel === undefined) {
|
||||
state.volumeLevel = this.volumeLevel(volumeInfo);
|
||||
}
|
||||
return {
|
||||
deviceInfo,
|
||||
state,
|
||||
apps,
|
||||
updatedAt: snapshotArg.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeApps(appsArg: IAndroidtvRemoteConfig['apps']): IAndroidtvRemoteApp[] {
|
||||
if (!appsArg) {
|
||||
return [];
|
||||
}
|
||||
if (Array.isArray(appsArg)) {
|
||||
return appsArg.map((appArg) => ({
|
||||
...appArg,
|
||||
name: appArg.name || androidtvRemoteKnownApps[appArg.id],
|
||||
}));
|
||||
}
|
||||
return Object.entries(appsArg).map(([id, appArg]) => this.normalizeConfiguredApp(id, appArg));
|
||||
}
|
||||
|
||||
private normalizeConfiguredApp(idArg: string, appArg: IAndroidtvRemoteConfiguredApp): IAndroidtvRemoteApp {
|
||||
return {
|
||||
id: idArg,
|
||||
name: appArg.name || appArg.appName || appArg.app_name || androidtvRemoteKnownApps[idArg],
|
||||
icon: appArg.icon || appArg.appIcon || appArg.app_icon,
|
||||
link: appArg.link,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeVolumeInfo(volumeInfoArg?: IAndroidtvRemoteVolumeInfo): IAndroidtvRemoteVolumeInfo | undefined {
|
||||
return volumeInfoArg ? { ...volumeInfoArg } : undefined;
|
||||
}
|
||||
|
||||
private volumeLevel(volumeInfoArg?: IAndroidtvRemoteVolumeInfo): number | undefined {
|
||||
if (!volumeInfoArg) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof volumeInfoArg.max === 'number' && volumeInfoArg.max > 0 && typeof volumeInfoArg.level === 'number') {
|
||||
return volumeInfoArg.level / volumeInfoArg.max;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async appForActivity(activityArg: string): Promise<IAndroidtvRemoteApp | undefined> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
return snapshot.apps.find((appArg) => activityArg === appArg.id || activityArg === appArg.name || activityArg === appArg.link);
|
||||
}
|
||||
|
||||
private appName(appsArg: IAndroidtvRemoteApp[], appIdArg: string): string | undefined {
|
||||
return appsArg.find((appArg) => appArg.id === appIdArg)?.name || androidtvRemoteKnownApps[appIdArg];
|
||||
}
|
||||
|
||||
private normalizeKeyCode(keyCodeArg: TAndroidtvRemoteKeyCode | string): TAndroidtvRemoteKeyCode | string {
|
||||
const raw = String(keyCodeArg).trim();
|
||||
if (!raw) {
|
||||
return raw;
|
||||
}
|
||||
const withoutPrefix = raw.toUpperCase().replace(/^KEYCODE_/, '').replace(/[\s-]+/g, '_');
|
||||
return androidtvRemoteKeyAliases[withoutPrefix] || withoutPrefix;
|
||||
}
|
||||
|
||||
private repeats(repeatsArg?: number): number {
|
||||
return typeof repeatsArg === 'number' && Number.isFinite(repeatsArg) ? Math.max(1, Math.floor(repeatsArg)) : 1;
|
||||
}
|
||||
|
||||
private hasUrlScheme(valueArg: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:/i.test(valueArg);
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IAndroidtvRemoteSnapshot): IAndroidtvRemoteSnapshot {
|
||||
return {
|
||||
deviceInfo: { ...snapshotArg.deviceInfo },
|
||||
state: {
|
||||
...snapshotArg.state,
|
||||
volumeInfo: snapshotArg.state.volumeInfo ? { ...snapshotArg.state.volumeInfo } : undefined,
|
||||
},
|
||||
apps: snapshotArg.apps.map((appArg) => ({ ...appArg })),
|
||||
updatedAt: snapshotArg.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import { androidtvRemoteApiPort, androidtvRemotePairPort } from './androidtv_remote.constants.js';
|
||||
import type { IAndroidtvRemoteConfig } from './androidtv_remote.types.js';
|
||||
|
||||
export class AndroidtvRemoteConfigFlow implements IConfigFlow<IAndroidtvRemoteConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAndroidtvRemoteConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Android TV Remote',
|
||||
description: 'Configure an Android TV Remote protocol v2 host. Pairing and live protocol transport require an injected executor.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||
{ name: 'apiPort', label: 'API port', type: 'number' },
|
||||
{ name: 'pairPort', label: 'Pairing port', type: 'number' },
|
||||
{ name: 'deviceName', label: 'Device name', type: 'text' },
|
||||
{ name: 'macAddress', label: 'MAC address', type: 'text' },
|
||||
{ name: 'enableIme', label: 'Enable IME updates', type: 'boolean' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const host = this.stringValue(valuesArg.host) || candidateArg.host;
|
||||
if (!host) {
|
||||
return { kind: 'error', title: 'Android TV Remote configuration failed', error: 'Host is required.' };
|
||||
}
|
||||
const apiPort = this.numberValue(valuesArg.apiPort) || this.numberValue(candidateArg.metadata?.apiPort) || candidateArg.port || androidtvRemoteApiPort;
|
||||
const pairPort = this.numberValue(valuesArg.pairPort) || this.numberValue(candidateArg.metadata?.pairPort) || androidtvRemotePairPort;
|
||||
const deviceName = this.stringValue(valuesArg.deviceName) || candidateArg.name;
|
||||
const macAddress = this.stringValue(valuesArg.macAddress) || candidateArg.macAddress || this.stringValue(candidateArg.metadata?.macAddress);
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Android TV Remote configured',
|
||||
config: {
|
||||
host,
|
||||
apiPort,
|
||||
pairPort,
|
||||
deviceName,
|
||||
macAddress,
|
||||
manufacturer: candidateArg.manufacturer,
|
||||
model: candidateArg.model,
|
||||
enableIme: typeof valuesArg.enableIme === 'boolean' ? valuesArg.enableIme : true,
|
||||
deviceInfo: {
|
||||
id: candidateArg.id || macAddress,
|
||||
name: deviceName,
|
||||
host,
|
||||
apiPort,
|
||||
pairPort,
|
||||
macAddress,
|
||||
manufacturer: candidateArg.manufacturer,
|
||||
model: candidateArg.model,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,236 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { AndroidtvRemoteClient } from './androidtv_remote.classes.client.js';
|
||||
import { AndroidtvRemoteConfigFlow } from './androidtv_remote.classes.configflow.js';
|
||||
import { createAndroidtvRemoteDiscoveryDescriptor } from './androidtv_remote.discovery.js';
|
||||
import { AndroidtvRemoteMapper } from './androidtv_remote.mapper.js';
|
||||
import type { IAndroidtvRemoteConfig } from './androidtv_remote.types.js';
|
||||
|
||||
export class HomeAssistantAndroidtvRemoteIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "androidtv_remote",
|
||||
displayName: "Android TV Remote",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/androidtv_remote",
|
||||
"upstreamDomain": "androidtv_remote",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_push",
|
||||
"qualityScale": "platinum",
|
||||
"requirements": [
|
||||
"androidtvremote2==0.3.1"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@tronikos",
|
||||
"@Drafteed"
|
||||
]
|
||||
},
|
||||
export class AndroidtvRemoteIntegration extends BaseIntegration<IAndroidtvRemoteConfig> {
|
||||
public readonly domain = 'androidtv_remote';
|
||||
public readonly displayName = 'Android TV Remote';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createAndroidtvRemoteDiscoveryDescriptor();
|
||||
public readonly configFlow = new AndroidtvRemoteConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/androidtv_remote',
|
||||
upstreamDomain: 'androidtv_remote',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_push',
|
||||
qualityScale: 'platinum',
|
||||
requirements: ['androidtvremote2==0.3.1'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@tronikos', '@Drafteed'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/androidtv_remote',
|
||||
};
|
||||
|
||||
public async setup(configArg: IAndroidtvRemoteConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new AndroidtvRemoteRuntime(new AndroidtvRemoteClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantAndroidtvRemoteIntegration extends AndroidtvRemoteIntegration {}
|
||||
|
||||
class AndroidtvRemoteRuntime implements IIntegrationRuntime {
|
||||
public domain = 'androidtv_remote';
|
||||
|
||||
constructor(private readonly client: AndroidtvRemoteClient) {}
|
||||
|
||||
public async devices(): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return AndroidtvRemoteMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return AndroidtvRemoteMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === 'remote') {
|
||||
return await this.callRemoteService(requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'androidtv_remote') {
|
||||
return await this.callAndroidtvRemoteService(requestArg);
|
||||
}
|
||||
if (requestArg.domain !== 'media_player') {
|
||||
return { success: false, error: `Unsupported Android TV Remote service domain: ${requestArg.domain}` };
|
||||
}
|
||||
return await this.callMediaPlayerService(requestArg);
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private async callRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'turn_on') {
|
||||
await this.client.turnOn();
|
||||
const activity = this.stringValue(requestArg.data?.activity);
|
||||
if (activity) {
|
||||
await this.client.selectActivity(activity);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
await this.client.turnOff();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service !== 'send_command') {
|
||||
return { success: false, error: `Unsupported Android TV Remote remote service: ${requestArg.service}` };
|
||||
}
|
||||
const command = requestArg.data?.command;
|
||||
const commands = typeof command === 'string' ? [command] : Array.isArray(command) ? command.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg)) : [];
|
||||
if (!commands.length) {
|
||||
return { success: false, error: 'Android TV Remote remote.send_command requires data.command.' };
|
||||
}
|
||||
await this.client.sendCommand(commands, {
|
||||
repeats: this.numberValue(requestArg.data?.num_repeats ?? requestArg.data?.numRepeats),
|
||||
delaySecs: this.numberValue(requestArg.data?.delay_secs ?? requestArg.data?.delaySecs),
|
||||
holdSecs: this.numberValue(requestArg.data?.hold_secs ?? requestArg.data?.holdSecs),
|
||||
reason: 'remote_send_command',
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async callAndroidtvRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'connect') {
|
||||
await this.client.connect();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'start_pairing') {
|
||||
await this.client.startPairing();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'finish_pairing') {
|
||||
const pin = this.stringValue(requestArg.data?.pin);
|
||||
if (!pin) {
|
||||
return { success: false, error: 'Android TV Remote finish_pairing requires data.pin.' };
|
||||
}
|
||||
await this.client.finishPairing(pin);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'launch_app') {
|
||||
const app = this.stringValue(requestArg.data?.app_id ?? requestArg.data?.appId ?? requestArg.data?.app_link ?? requestArg.data?.appLink);
|
||||
if (!app) {
|
||||
return { success: false, error: 'Android TV Remote launch_app requires data.app_id or data.app_link.' };
|
||||
}
|
||||
await this.client.launchApp(app);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'send_text') {
|
||||
const text = this.stringValue(requestArg.data?.text);
|
||||
if (!text) {
|
||||
return { success: false, error: 'Android TV Remote send_text requires data.text.' };
|
||||
}
|
||||
await this.client.sendText(text);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: `Unsupported Android TV Remote service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'turn_on') {
|
||||
await this.client.turnOn();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
await this.client.turnOff();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'play' || requestArg.service === 'media_play') {
|
||||
await this.client.mediaPlay();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'pause' || requestArg.service === 'media_pause') {
|
||||
await this.client.mediaPause();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'play_pause' || requestArg.service === 'media_play_pause') {
|
||||
await this.client.mediaPlayPause();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'stop' || requestArg.service === 'media_stop') {
|
||||
await this.client.mediaStop();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'next_track' || requestArg.service === 'media_next_track') {
|
||||
await this.client.mediaNextTrack();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') {
|
||||
await this.client.mediaPreviousTrack();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_up') {
|
||||
await this.client.volumeUp();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_down') {
|
||||
await this.client.volumeDown();
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') {
|
||||
const muted = requestArg.data?.is_volume_muted ?? requestArg.data?.muted;
|
||||
if (typeof muted !== 'boolean') {
|
||||
return { success: false, error: 'Android TV Remote volume_mute requires data.is_volume_muted.' };
|
||||
}
|
||||
await this.client.muteVolume(muted);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'volume_set') {
|
||||
const level = requestArg.data?.volume_level;
|
||||
if (typeof level !== 'number') {
|
||||
return { success: false, error: 'Android TV Remote volume_set requires data.volume_level.' };
|
||||
}
|
||||
await this.client.setVolumeLevel(level);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'select_source') {
|
||||
const source = this.stringValue(requestArg.data?.source);
|
||||
if (!source) {
|
||||
return { success: false, error: 'Android TV Remote select_source requires data.source.' };
|
||||
}
|
||||
await this.client.selectActivity(source);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'play_media') {
|
||||
return await this.callPlayMediaService(requestArg);
|
||||
}
|
||||
return { success: false, error: `Unsupported Android TV Remote media_player service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private async callPlayMediaService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
const mediaId = this.stringValue(requestArg.data?.media_content_id ?? requestArg.data?.media_id ?? requestArg.data?.uri);
|
||||
const mediaType = this.stringValue(requestArg.data?.media_content_type ?? requestArg.data?.media_type);
|
||||
if (!mediaId) {
|
||||
return { success: false, error: 'Android TV Remote play_media requires data.media_content_id.' };
|
||||
}
|
||||
if (mediaType === 'channel') {
|
||||
await this.client.playChannel(mediaId);
|
||||
return { success: true };
|
||||
}
|
||||
if (mediaType === 'app' || mediaType === 'url') {
|
||||
await this.client.launchApp(mediaId);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: `Unsupported Android TV Remote media type: ${mediaType || 'unknown'}` };
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { TAndroidtvRemoteKeyCode } from './androidtv_remote.types.js';
|
||||
|
||||
export const androidtvRemoteApiPort = 6466;
|
||||
export const androidtvRemotePairPort = 6467;
|
||||
export const androidtvRemoteMdnsService = '_androidtvremote2._tcp.local.';
|
||||
|
||||
export const androidtvRemoteKnownApps: Record<string, string> = {
|
||||
'com.android.tv.settings': 'Settings',
|
||||
'com.disney.disneyplus': 'Disney+',
|
||||
'com.google.android.apps.tv.launcherx': 'Google TV Launcher',
|
||||
'com.google.android.tvlauncher': 'Android TV Launcher',
|
||||
'com.google.android.youtube.tv': 'YouTube',
|
||||
'com.google.android.youtube.tvkids': 'YouTube Kids',
|
||||
'com.google.android.youtube.tvmusic': 'YouTube Music',
|
||||
'com.hbo.hbonow': 'HBO Max',
|
||||
'com.hulu.plus': 'Hulu',
|
||||
'com.netflix.ninja': 'Netflix',
|
||||
'com.plexapp.android': 'Plex',
|
||||
'com.spotify.tv.android': 'Spotify',
|
||||
'org.jellyfin.androidtv': 'Jellyfin',
|
||||
'org.videolan.vlc': 'VLC',
|
||||
'org.xbmc.kodi': 'Kodi',
|
||||
'tv.twitch.android.app': 'Twitch',
|
||||
};
|
||||
|
||||
export const androidtvRemoteKeyAliases: Record<string, TAndroidtvRemoteKeyCode> = {
|
||||
BLUE: 'PROG_BLUE',
|
||||
CENTER: 'DPAD_CENTER',
|
||||
CH_DOWN: 'CHANNEL_DOWN',
|
||||
CH_UP: 'CHANNEL_UP',
|
||||
CHANNELDOWN: 'CHANNEL_DOWN',
|
||||
CHANNELUP: 'CHANNEL_UP',
|
||||
DOWN: 'DPAD_DOWN',
|
||||
FAST_FORWARD: 'MEDIA_FAST_FORWARD',
|
||||
FORWARD: 'MEDIA_FAST_FORWARD',
|
||||
GREEN: 'PROG_GREEN',
|
||||
INFO: 'INFO',
|
||||
LEFT: 'DPAD_LEFT',
|
||||
NEXT: 'MEDIA_NEXT',
|
||||
PAUSE: 'MEDIA_PAUSE',
|
||||
PLAY: 'MEDIA_PLAY',
|
||||
PLAY_PAUSE: 'MEDIA_PLAY_PAUSE',
|
||||
PREVIOUS: 'MEDIA_PREVIOUS',
|
||||
RED: 'PROG_RED',
|
||||
REWIND: 'MEDIA_REWIND',
|
||||
RIGHT: 'DPAD_RIGHT',
|
||||
SELECT: 'DPAD_CENTER',
|
||||
STOP: 'MEDIA_STOP',
|
||||
UP: 'DPAD_UP',
|
||||
VOL_DOWN: 'VOLUME_DOWN',
|
||||
VOL_UP: 'VOLUME_UP',
|
||||
VOLUMEDOWN: 'VOLUME_DOWN',
|
||||
VOLUMEMUTE: 'VOLUME_MUTE',
|
||||
VOLUMEUP: 'VOLUME_UP',
|
||||
YELLOW: 'PROG_YELLOW',
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import { androidtvRemoteApiPort, androidtvRemoteMdnsService, androidtvRemotePairPort } from './androidtv_remote.constants.js';
|
||||
import type { IAndroidtvRemoteManualEntry, IAndroidtvRemoteMdnsRecord } from './androidtv_remote.types.js';
|
||||
|
||||
export class AndroidtvRemoteMdnsMatcher implements IDiscoveryMatcher<IAndroidtvRemoteMdnsRecord> {
|
||||
public id = 'androidtv-remote-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize Android TV Remote protocol v2 mDNS advertisements.';
|
||||
|
||||
public async matches(recordArg: IAndroidtvRemoteMdnsRecord): Promise<IDiscoveryMatch> {
|
||||
const type = this.stringValue(recordArg.type || recordArg.metadata?.type).toLowerCase();
|
||||
const name = this.stringValue(recordArg.name || recordArg.metadata?.name);
|
||||
const matched = type.includes('androidtvremote2') || name.toLowerCase().includes(androidtvRemoteMdnsService);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record is not an Android TV Remote advertisement.' };
|
||||
}
|
||||
const properties = { ...(recordArg.txt || {}), ...(recordArg.properties || {}) };
|
||||
const macAddress = this.stringValue(properties.bt || properties.mac || properties.macAddress || recordArg.metadata?.macAddress);
|
||||
const id = this.stringValue(properties.id || macAddress);
|
||||
const displayName = this.displayName(name);
|
||||
const apiPort = this.numberValue(recordArg.port) || androidtvRemoteApiPort;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: recordArg.host && macAddress ? 'certain' : recordArg.host ? 'high' : 'medium',
|
||||
reason: 'mDNS record matches Android TV Remote protocol v2.',
|
||||
normalizedDeviceId: id || recordArg.host,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: 'androidtv_remote',
|
||||
id,
|
||||
host: recordArg.host,
|
||||
port: apiPort,
|
||||
name: displayName,
|
||||
macAddress,
|
||||
metadata: {
|
||||
type: recordArg.type,
|
||||
txt: properties,
|
||||
apiPort,
|
||||
pairPort: androidtvRemotePairPort,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private displayName(nameArg: string): string | undefined {
|
||||
const name = nameArg.replace(androidtvRemoteMdnsService, '').replace(/\.$/, '').trim();
|
||||
return name || undefined;
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : '';
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class AndroidtvRemoteManualMatcher implements IDiscoveryMatcher<IAndroidtvRemoteManualEntry> {
|
||||
public id = 'androidtv-remote-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Android TV Remote host entries.';
|
||||
|
||||
public async matches(inputArg: IAndroidtvRemoteManualEntry): Promise<IDiscoveryMatch> {
|
||||
const matched = Boolean(inputArg.host || inputArg.metadata?.androidtvRemote || inputArg.metadata?.androidtv_remote);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not include an Android TV Remote host.' };
|
||||
}
|
||||
const apiPort = this.numberValue(inputArg.apiPort || inputArg.port || inputArg.metadata?.apiPort) || androidtvRemoteApiPort;
|
||||
const pairPort = this.numberValue(inputArg.pairPort || inputArg.metadata?.pairPort) || androidtvRemotePairPort;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Android TV Remote setup.',
|
||||
normalizedDeviceId: inputArg.id || inputArg.macAddress || inputArg.host,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: 'androidtv_remote',
|
||||
id: inputArg.id || inputArg.macAddress,
|
||||
host: inputArg.host,
|
||||
port: apiPort,
|
||||
name: inputArg.deviceName || inputArg.name,
|
||||
manufacturer: inputArg.manufacturer,
|
||||
model: inputArg.model,
|
||||
macAddress: inputArg.macAddress,
|
||||
metadata: {
|
||||
...inputArg.metadata,
|
||||
apiPort,
|
||||
pairPort,
|
||||
enableIme: inputArg.enableIme,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class AndroidtvRemoteCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'androidtv-remote-candidate-validator';
|
||||
public description = 'Validate Android TV Remote candidates.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const matched = candidateArg.integrationDomain === 'androidtv_remote' || Boolean(candidateArg.metadata?.androidtvRemote || candidateArg.metadata?.androidtv_remote);
|
||||
return {
|
||||
matched,
|
||||
confidence: matched && candidateArg.host && (candidateArg.id || candidateArg.macAddress) ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Candidate has Android TV Remote metadata.' : 'Candidate is not Android TV Remote.',
|
||||
candidate: matched ? candidateArg : undefined,
|
||||
normalizedDeviceId: candidateArg.id || candidateArg.macAddress || candidateArg.host,
|
||||
metadata: matched ? {
|
||||
apiPort: candidateArg.port || candidateArg.metadata?.apiPort || androidtvRemoteApiPort,
|
||||
pairPort: candidateArg.metadata?.pairPort || androidtvRemotePairPort,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createAndroidtvRemoteDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: 'androidtv_remote', displayName: 'Android TV Remote' })
|
||||
.addMatcher(new AndroidtvRemoteMdnsMatcher())
|
||||
.addMatcher(new AndroidtvRemoteManualMatcher())
|
||||
.addValidator(new AndroidtvRemoteCandidateValidator());
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import { androidtvRemoteKnownApps } from './androidtv_remote.constants.js';
|
||||
import type { IAndroidtvRemoteApp, IAndroidtvRemoteSnapshot, IAndroidtvRemoteVolumeInfo } from './androidtv_remote.types.js';
|
||||
|
||||
export class AndroidtvRemoteMapper {
|
||||
public static toDevices(snapshotArg: IAndroidtvRemoteSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
return [{
|
||||
id: this.deviceId(snapshotArg),
|
||||
integrationDomain: 'androidtv_remote',
|
||||
name: this.deviceName(snapshotArg),
|
||||
protocol: 'unknown',
|
||||
manufacturer: snapshotArg.deviceInfo.manufacturer || 'Android',
|
||||
model: snapshotArg.deviceInfo.model || 'Android TV Remote',
|
||||
online: this.available(snapshotArg),
|
||||
features: [
|
||||
{ id: 'power', capability: 'media', name: 'Power', readable: true, writable: true },
|
||||
{ id: 'media_state', capability: 'media', name: 'Media state', readable: true, writable: false },
|
||||
{ id: 'activity', capability: 'media', name: 'Activity', readable: true, writable: true },
|
||||
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true },
|
||||
{ id: 'mute', capability: 'media', name: 'Mute', readable: true, writable: true },
|
||||
{ id: 'remote_key', capability: 'media', name: 'Remote key', readable: false, writable: true },
|
||||
],
|
||||
state: [
|
||||
{ featureId: 'power', value: this.powerState(snapshotArg), updatedAt },
|
||||
{ featureId: 'media_state', value: this.mediaState(snapshotArg), updatedAt },
|
||||
{ featureId: 'activity', value: this.activity(snapshotArg) || null, updatedAt },
|
||||
{ featureId: 'volume', value: this.volumePercent(snapshotArg), updatedAt },
|
||||
{ featureId: 'mute', value: this.muted(snapshotArg), updatedAt },
|
||||
],
|
||||
metadata: {
|
||||
host: snapshotArg.deviceInfo.host,
|
||||
protocol: 'androidtvremote2',
|
||||
apiPort: snapshotArg.deviceInfo.apiPort,
|
||||
pairPort: snapshotArg.deviceInfo.pairPort,
|
||||
macAddress: snapshotArg.deviceInfo.macAddress,
|
||||
softwareVersion: snapshotArg.deviceInfo.softwareVersion,
|
||||
appVersion: snapshotArg.deviceInfo.appVersion,
|
||||
currentApp: snapshotArg.state.currentApp,
|
||||
voiceEnabled: snapshotArg.state.voiceEnabled,
|
||||
apps: snapshotArg.apps.map((appArg) => ({ id: appArg.id, name: this.appName(appArg), icon: appArg.icon, link: appArg.link })),
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IAndroidtvRemoteSnapshot): IIntegrationEntity[] {
|
||||
return [{
|
||||
id: `media_player.${this.slug(this.deviceName(snapshotArg))}`,
|
||||
uniqueId: `androidtv_remote_${this.slug(this.stableDeviceKey(snapshotArg))}`,
|
||||
integrationDomain: 'androidtv_remote',
|
||||
deviceId: this.deviceId(snapshotArg),
|
||||
platform: 'media_player',
|
||||
name: this.deviceName(snapshotArg),
|
||||
state: this.mediaState(snapshotArg),
|
||||
attributes: {
|
||||
appId: snapshotArg.state.currentApp,
|
||||
appName: snapshotArg.state.currentAppName || (snapshotArg.state.currentApp ? this.appNameById(snapshotArg, snapshotArg.state.currentApp) : undefined),
|
||||
currentActivity: this.activity(snapshotArg),
|
||||
activityList: this.activityList(snapshotArg),
|
||||
source: this.activity(snapshotArg),
|
||||
sourceList: this.activityList(snapshotArg),
|
||||
volumeLevel: this.normalizedVolumeLevel(snapshotArg),
|
||||
isVolumeMuted: this.muted(snapshotArg),
|
||||
assumedState: true,
|
||||
voiceEnabled: snapshotArg.state.voiceEnabled,
|
||||
rawState: snapshotArg.state.rawState,
|
||||
},
|
||||
available: this.available(snapshotArg),
|
||||
}];
|
||||
}
|
||||
|
||||
public static mediaState(snapshotArg: IAndroidtvRemoteSnapshot): string {
|
||||
if (!this.available(snapshotArg)) {
|
||||
return 'unavailable';
|
||||
}
|
||||
const rawState = String(snapshotArg.state.mediaState || snapshotArg.state.rawState || '').toLowerCase();
|
||||
if (snapshotArg.state.isOn === false || snapshotArg.state.powerState === 'off' || rawState === 'off') {
|
||||
return 'off';
|
||||
}
|
||||
if (['playing', 'paused', 'stopped', 'idle', 'buffering', 'on'].includes(rawState)) {
|
||||
return rawState;
|
||||
}
|
||||
if (snapshotArg.state.isOn === true || snapshotArg.state.currentApp) {
|
||||
return 'on';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
public static powerState(snapshotArg: IAndroidtvRemoteSnapshot): string {
|
||||
if (snapshotArg.state.isOn === true || snapshotArg.state.powerState === 'on') {
|
||||
return 'on';
|
||||
}
|
||||
if (snapshotArg.state.isOn === false || snapshotArg.state.powerState === 'off' || String(snapshotArg.state.mediaState || '').toLowerCase() === 'off') {
|
||||
return 'off';
|
||||
}
|
||||
return this.available(snapshotArg) && snapshotArg.state.currentApp ? 'on' : 'unknown';
|
||||
}
|
||||
|
||||
private static available(snapshotArg: IAndroidtvRemoteSnapshot): boolean {
|
||||
return snapshotArg.state.available !== false;
|
||||
}
|
||||
|
||||
private static activity(snapshotArg: IAndroidtvRemoteSnapshot): string | undefined {
|
||||
return snapshotArg.state.currentActivity || snapshotArg.state.currentAppName || (snapshotArg.state.currentApp ? this.appNameById(snapshotArg, snapshotArg.state.currentApp) || snapshotArg.state.currentApp : undefined);
|
||||
}
|
||||
|
||||
private static activityList(snapshotArg: IAndroidtvRemoteSnapshot): string[] {
|
||||
const activities = new Set<string>();
|
||||
for (const appArg of snapshotArg.apps) {
|
||||
const name = this.appName(appArg);
|
||||
if (name) {
|
||||
activities.add(name);
|
||||
}
|
||||
}
|
||||
return [...activities];
|
||||
}
|
||||
|
||||
private static appNameById(snapshotArg: IAndroidtvRemoteSnapshot, appIdArg: string): string | undefined {
|
||||
return snapshotArg.apps.find((appArg) => appArg.id === appIdArg)?.name || androidtvRemoteKnownApps[appIdArg];
|
||||
}
|
||||
|
||||
private static appName(appArg: IAndroidtvRemoteApp): string {
|
||||
return appArg.name || androidtvRemoteKnownApps[appArg.id] || appArg.id;
|
||||
}
|
||||
|
||||
private static normalizedVolumeLevel(snapshotArg: IAndroidtvRemoteSnapshot): number | undefined {
|
||||
if (typeof snapshotArg.state.volumeLevel === 'number') {
|
||||
return Math.max(0, Math.min(1, snapshotArg.state.volumeLevel));
|
||||
}
|
||||
return this.volumeLevelFromInfo(snapshotArg.state.volumeInfo);
|
||||
}
|
||||
|
||||
private static volumePercent(snapshotArg: IAndroidtvRemoteSnapshot): number | null {
|
||||
const level = this.normalizedVolumeLevel(snapshotArg);
|
||||
return typeof level === 'number' ? Math.round(level * 100) : null;
|
||||
}
|
||||
|
||||
private static volumeLevelFromInfo(volumeInfoArg?: IAndroidtvRemoteVolumeInfo): number | undefined {
|
||||
if (!volumeInfoArg || typeof volumeInfoArg.level !== 'number' || typeof volumeInfoArg.max !== 'number' || volumeInfoArg.max <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(0, Math.min(1, volumeInfoArg.level / volumeInfoArg.max));
|
||||
}
|
||||
|
||||
private static muted(snapshotArg: IAndroidtvRemoteSnapshot): boolean | null {
|
||||
return snapshotArg.state.isVolumeMuted ?? snapshotArg.state.volumeInfo?.muted ?? null;
|
||||
}
|
||||
|
||||
private static deviceId(snapshotArg: IAndroidtvRemoteSnapshot): string {
|
||||
return `androidtv_remote.device.${this.slug(this.stableDeviceKey(snapshotArg))}`;
|
||||
}
|
||||
|
||||
private static stableDeviceKey(snapshotArg: IAndroidtvRemoteSnapshot): string {
|
||||
return snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.macAddress || snapshotArg.deviceInfo.host || this.deviceName(snapshotArg);
|
||||
}
|
||||
|
||||
private static deviceName(snapshotArg: IAndroidtvRemoteSnapshot): string {
|
||||
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.model || snapshotArg.deviceInfo.host || 'Android TV Remote';
|
||||
}
|
||||
|
||||
private static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'androidtv_remote';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,254 @@
|
||||
export interface IHomeAssistantAndroidtvRemoteConfig {
|
||||
// TODO: replace with the TypeScript-native config for androidtv_remote.
|
||||
[key: string]: unknown;
|
||||
export type TAndroidtvRemoteMediaState =
|
||||
| 'off'
|
||||
| 'on'
|
||||
| 'idle'
|
||||
| 'playing'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'buffering'
|
||||
| 'unknown';
|
||||
|
||||
export type TAndroidtvRemotePowerState = 'on' | 'off' | 'unknown';
|
||||
|
||||
export type TAndroidtvRemoteCommandDirection = 'SHORT' | 'START_LONG' | 'END_LONG';
|
||||
|
||||
export type TAndroidtvRemoteKeyCode =
|
||||
| '0'
|
||||
| '1'
|
||||
| '2'
|
||||
| '3'
|
||||
| '4'
|
||||
| '5'
|
||||
| '6'
|
||||
| '7'
|
||||
| '8'
|
||||
| '9'
|
||||
| 'ASSIST'
|
||||
| 'BACK'
|
||||
| 'BUTTON_A'
|
||||
| 'BUTTON_B'
|
||||
| 'BUTTON_MODE'
|
||||
| 'BUTTON_X'
|
||||
| 'BUTTON_Y'
|
||||
| 'CAPTIONS'
|
||||
| 'CHANNEL_DOWN'
|
||||
| 'CHANNEL_UP'
|
||||
| 'DEL'
|
||||
| 'DPAD_CENTER'
|
||||
| 'DPAD_DOWN'
|
||||
| 'DPAD_LEFT'
|
||||
| 'DPAD_RIGHT'
|
||||
| 'DPAD_UP'
|
||||
| 'DVR'
|
||||
| 'ENTER'
|
||||
| 'EXPLORER'
|
||||
| 'F1'
|
||||
| 'F2'
|
||||
| 'F3'
|
||||
| 'F4'
|
||||
| 'F5'
|
||||
| 'F6'
|
||||
| 'F7'
|
||||
| 'F8'
|
||||
| 'F9'
|
||||
| 'F10'
|
||||
| 'F11'
|
||||
| 'F12'
|
||||
| 'GUIDE'
|
||||
| 'HOME'
|
||||
| 'INFO'
|
||||
| 'MEDIA_AUDIO_TRACK'
|
||||
| 'MEDIA_FAST_FORWARD'
|
||||
| 'MEDIA_NEXT'
|
||||
| 'MEDIA_PAUSE'
|
||||
| 'MEDIA_PLAY'
|
||||
| 'MEDIA_PLAY_PAUSE'
|
||||
| 'MEDIA_PREVIOUS'
|
||||
| 'MEDIA_RECORD'
|
||||
| 'MEDIA_REWIND'
|
||||
| 'MEDIA_STOP'
|
||||
| 'MENU'
|
||||
| 'MUTE'
|
||||
| 'POWER'
|
||||
| 'PROG_BLUE'
|
||||
| 'PROG_GREEN'
|
||||
| 'PROG_RED'
|
||||
| 'PROG_YELLOW'
|
||||
| 'SEARCH'
|
||||
| 'SETTINGS'
|
||||
| 'TV'
|
||||
| 'TV_TELETEXT'
|
||||
| 'VOLUME_DOWN'
|
||||
| 'VOLUME_MUTE'
|
||||
| 'VOLUME_UP';
|
||||
|
||||
export type TAndroidtvRemoteCommandReason =
|
||||
| 'connect'
|
||||
| 'finish_pairing'
|
||||
| 'launch_app'
|
||||
| 'media_next_track'
|
||||
| 'media_pause'
|
||||
| 'media_play'
|
||||
| 'media_play_pause'
|
||||
| 'media_previous_track'
|
||||
| 'media_stop'
|
||||
| 'play_channel'
|
||||
| 'remote_send_command'
|
||||
| 'select_activity'
|
||||
| 'send_text'
|
||||
| 'start_pairing'
|
||||
| 'turn_off'
|
||||
| 'turn_on'
|
||||
| 'volume_down'
|
||||
| 'volume_mute'
|
||||
| 'volume_set'
|
||||
| 'volume_up';
|
||||
|
||||
export type TAndroidtvRemoteCommandAction =
|
||||
| 'connect'
|
||||
| 'finish_pairing'
|
||||
| 'key_command'
|
||||
| 'launch_app'
|
||||
| 'remote_send_command'
|
||||
| 'send_text'
|
||||
| 'start_pairing'
|
||||
| 'volume_set';
|
||||
|
||||
export interface IAndroidtvRemoteVolumeInfo {
|
||||
level?: number;
|
||||
max?: number;
|
||||
muted?: boolean;
|
||||
playerModel?: string;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteDeviceInfo {
|
||||
id?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
apiPort?: number;
|
||||
pairPort?: number;
|
||||
macAddress?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
softwareVersion?: string;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteDeviceState {
|
||||
available?: boolean;
|
||||
isOn?: boolean | null;
|
||||
powerState?: TAndroidtvRemotePowerState;
|
||||
mediaState?: TAndroidtvRemoteMediaState | string;
|
||||
rawState?: string;
|
||||
currentApp?: string;
|
||||
currentAppName?: string;
|
||||
currentActivity?: string;
|
||||
volumeInfo?: IAndroidtvRemoteVolumeInfo;
|
||||
volumeLevel?: number;
|
||||
isVolumeMuted?: boolean;
|
||||
voiceEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteApp {
|
||||
id: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteConfiguredApp {
|
||||
appName?: string;
|
||||
appIcon?: string;
|
||||
app_name?: string;
|
||||
app_icon?: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteSnapshot {
|
||||
deviceInfo: IAndroidtvRemoteDeviceInfo;
|
||||
state: IAndroidtvRemoteDeviceState;
|
||||
apps: IAndroidtvRemoteApp[];
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteKeyPress {
|
||||
keyCode: TAndroidtvRemoteKeyCode | string;
|
||||
direction?: TAndroidtvRemoteCommandDirection;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteCommand {
|
||||
action: TAndroidtvRemoteCommandAction;
|
||||
reason?: TAndroidtvRemoteCommandReason;
|
||||
keyCode?: TAndroidtvRemoteKeyCode | string;
|
||||
direction?: TAndroidtvRemoteCommandDirection;
|
||||
keys?: IAndroidtvRemoteKeyPress[];
|
||||
appId?: string;
|
||||
appLink?: string;
|
||||
appName?: string;
|
||||
text?: string;
|
||||
pin?: string;
|
||||
volumeLevel?: number;
|
||||
muted?: boolean;
|
||||
repeats?: number;
|
||||
delaySecs?: number;
|
||||
holdSecs?: number;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteCommandContext {
|
||||
config: IAndroidtvRemoteConfig;
|
||||
snapshot: IAndroidtvRemoteSnapshot;
|
||||
}
|
||||
|
||||
export type TAndroidtvRemoteCommandExecutor =
|
||||
| ((commandArg: IAndroidtvRemoteCommand, contextArg: IAndroidtvRemoteCommandContext) => Promise<void> | void)
|
||||
| {
|
||||
execute(commandArg: IAndroidtvRemoteCommand, contextArg: IAndroidtvRemoteCommandContext): Promise<void> | void;
|
||||
};
|
||||
|
||||
export interface IAndroidtvRemoteConfig {
|
||||
host?: string;
|
||||
apiPort?: number;
|
||||
pairPort?: number;
|
||||
deviceName?: string;
|
||||
macAddress?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
enableIme?: boolean;
|
||||
deviceInfo?: IAndroidtvRemoteDeviceInfo;
|
||||
state?: IAndroidtvRemoteDeviceState;
|
||||
volumeInfo?: IAndroidtvRemoteVolumeInfo;
|
||||
apps?: IAndroidtvRemoteApp[] | Record<string, IAndroidtvRemoteConfiguredApp>;
|
||||
snapshot?: IAndroidtvRemoteSnapshot;
|
||||
executor?: TAndroidtvRemoteCommandExecutor;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteMdnsRecord {
|
||||
type?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
txt?: Record<string, unknown>;
|
||||
properties?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAndroidtvRemoteManualEntry {
|
||||
host?: string;
|
||||
apiPort?: number;
|
||||
pairPort?: number;
|
||||
port?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
deviceName?: string;
|
||||
macAddress?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
enableIme?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TAndroidtvRemoteDiscoveryRecord = IAndroidtvRemoteMdnsRecord | IAndroidtvRemoteManualEntry;
|
||||
|
||||
export type IHomeAssistantAndroidtvRemoteConfig = IAndroidtvRemoteConfig;
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export * from './androidtv_remote.classes.client.js';
|
||||
export * from './androidtv_remote.classes.configflow.js';
|
||||
export * from './androidtv_remote.classes.integration.js';
|
||||
export * from './androidtv_remote.constants.js';
|
||||
export * from './androidtv_remote.discovery.js';
|
||||
export * from './androidtv_remote.mapper.js';
|
||||
export * from './androidtv_remote.types.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,748 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
IArcamFmjCommandRequest,
|
||||
IArcamFmjCommandResult,
|
||||
IArcamFmjConfig,
|
||||
IArcamFmjDeviceInfo,
|
||||
IArcamFmjIncomingAudioInfo,
|
||||
IArcamFmjIncomingVideoParameters,
|
||||
IArcamFmjModeledCommand,
|
||||
IArcamFmjResponsePacket,
|
||||
IArcamFmjSnapshot,
|
||||
IArcamFmjZoneState,
|
||||
TArcamFmjApiModel,
|
||||
TArcamFmjSnapshotSource,
|
||||
TArcamFmjSource,
|
||||
} from './arcam_fmj.types.js';
|
||||
import { arcamFmjDefaultPort } from './arcam_fmj.types.js';
|
||||
|
||||
const defaultTimeoutMs = 3000;
|
||||
const protocolStart = 0x21;
|
||||
const protocolEnd = 0x0d;
|
||||
const queryByte = 0xf0;
|
||||
|
||||
const commandCodes = {
|
||||
POWER: 0x00,
|
||||
SIMULATE_RC5_IR_COMMAND: 0x08,
|
||||
VOLUME: 0x0d,
|
||||
MUTE: 0x0e,
|
||||
DECODE_MODE_STATUS_2CH: 0x10,
|
||||
DECODE_MODE_STATUS_MCH: 0x11,
|
||||
RDS_INFORMATION: 0x12,
|
||||
MENU: 0x14,
|
||||
TUNER_PRESET: 0x15,
|
||||
DAB_STATION: 0x18,
|
||||
DLS_PDT_INFO: 0x1a,
|
||||
PRESET_DETAIL: 0x1b,
|
||||
CURRENT_SOURCE: 0x1d,
|
||||
INCOMING_VIDEO_PARAMETERS: 0x42,
|
||||
INCOMING_AUDIO_FORMAT: 0x43,
|
||||
INCOMING_AUDIO_SAMPLE_RATE: 0x44,
|
||||
} as const;
|
||||
|
||||
const commandNames = Object.fromEntries(
|
||||
Object.entries(commandCodes).map(([key, value]) => [value, key])
|
||||
) as Record<number, string>;
|
||||
|
||||
const directPowerWriteSupported = new Set<TArcamFmjApiModel>(['APISA_SERIES', 'APIPA_SERIES', 'APIST_SERIES']);
|
||||
const directMuteWriteSupported = directPowerWriteSupported;
|
||||
const directSourceWriteSupported = new Set<TArcamFmjApiModel>(['APISA_SERIES']);
|
||||
const volumeStepSupported = new Set<TArcamFmjApiModel>(['APIST_SERIES']);
|
||||
|
||||
const modelSeries: Array<{ apiModel: TArcamFmjApiModel; models: string[] }> = [
|
||||
{ apiModel: 'API450_SERIES', models: ['AVR380', 'AVR450', 'AVR750'] },
|
||||
{ apiModel: 'API860_SERIES', models: ['AV860', 'AVR850', 'AVR550', 'AVR390', 'SR250', 'RV-6', 'RV-9', 'MC-10'] },
|
||||
{ apiModel: 'APIHDA_SERIES', models: ['AVR5', 'AVR10', 'AVR20', 'AVR30', 'AV40', 'AVR11', 'AVR21', 'AVR31', 'AV41', 'SDP-55', 'SDP-58'] },
|
||||
{ apiModel: 'APISA_SERIES', models: ['SA10', 'SA20', 'SA30', 'SA750'] },
|
||||
{ apiModel: 'APIPA_SERIES', models: ['PA720', 'PA240', 'PA410'] },
|
||||
{ apiModel: 'APIST_SERIES', models: ['ST60'] },
|
||||
];
|
||||
|
||||
const statusSourceMaps: Record<TArcamFmjApiModel, Partial<Record<number, Record<string, number>>>> = {
|
||||
API450_SERIES: {
|
||||
1: { FOLLOW_ZONE_1: 0x00, CD: 0x01, BD: 0x02, AV: 0x03, SAT: 0x04, PVR: 0x05, VCR: 0x06, AUX: 0x08, DISPLAY: 0x09, FM: 0x0b, DAB: 0x0c, NET: 0x0e, USB: 0x0f, STB: 0x10, GAME: 0x11, PHONO: 0x12, ARC_ERC: 0x13 },
|
||||
2: { FOLLOW_ZONE_1: 0x00, CD: 0x01, BD: 0x02, AV: 0x03, SAT: 0x04, PVR: 0x05, VCR: 0x06, AUX: 0x08, DISPLAY: 0x09, FM: 0x0b, DAB: 0x0c, NET: 0x0e, USB: 0x0f, STB: 0x10, GAME: 0x11, PHONO: 0x12, ARC_ERC: 0x13 },
|
||||
},
|
||||
API860_SERIES: {
|
||||
1: { FOLLOW_ZONE_1: 0x00, CD: 0x01, BD: 0x02, AV: 0x03, SAT: 0x04, PVR: 0x05, VCR: 0x06, AUX: 0x08, DISPLAY: 0x09, FM: 0x0b, DAB: 0x0c, NET: 0x0e, USB: 0x0f, STB: 0x10, GAME: 0x11, PHONO: 0x12, ARC_ERC: 0x13 },
|
||||
2: { FOLLOW_ZONE_1: 0x00, CD: 0x01, BD: 0x02, AV: 0x03, SAT: 0x04, PVR: 0x05, VCR: 0x06, AUX: 0x08, DISPLAY: 0x09, FM: 0x0b, DAB: 0x0c, NET: 0x0e, USB: 0x0f, STB: 0x10, GAME: 0x11, PHONO: 0x12, ARC_ERC: 0x13 },
|
||||
},
|
||||
APIHDA_SERIES: {
|
||||
1: { FOLLOW_ZONE_1: 0x00, CD: 0x01, BD: 0x02, AV: 0x03, SAT: 0x04, PVR: 0x05, UHD: 0x06, AUX: 0x08, DISPLAY: 0x09, FM: 0x0b, DAB: 0x0c, NET: 0x0e, USB: 0x0f, STB: 0x10, GAME: 0x11, BT: 0x12 },
|
||||
2: { FOLLOW_ZONE_1: 0x00, CD: 0x01, BD: 0x02, AV: 0x03, SAT: 0x04, PVR: 0x05, UHD: 0x06, AUX: 0x08, DISPLAY: 0x09, FM: 0x0b, DAB: 0x0c, NET: 0x0e, USB: 0x0f, STB: 0x10, GAME: 0x11, BT: 0x12 },
|
||||
},
|
||||
APISA_SERIES: {
|
||||
1: { PHONO: 0x01, AUX: 0x02, PVR: 0x03, AV: 0x04, STB: 0x05, CD: 0x06, BD: 0x07, SAT: 0x08, GAME: 0x09, NET: 0x0b, USB: 0x0b, ARC_ERC: 0x0d },
|
||||
2: { PHONO: 0x01, AUX: 0x02, PVR: 0x03, AV: 0x04, STB: 0x05, CD: 0x06, BD: 0x07, SAT: 0x08, GAME: 0x09, NET: 0x0b, USB: 0x0b, ARC_ERC: 0x0d },
|
||||
},
|
||||
APIPA_SERIES: {},
|
||||
APIST_SERIES: {
|
||||
1: { DIG1: 0x01, DIG2: 0x02, DIG3: 0x03, DIG4: 0x04, NET_USB: 0x05 },
|
||||
},
|
||||
};
|
||||
|
||||
const rc5SourceMaps: Record<TArcamFmjApiModel, Partial<Record<number, Record<string, number[]>>>> = {
|
||||
API450_SERIES: {
|
||||
1: { STB: [16, 1], AV: [16, 2], DAB: [16, 72], FM: [16, 54], BD: [16, 4], GAME: [16, 5], VCR: [16, 6], CD: [16, 7], AUX: [16, 8], DISPLAY: [16, 9], SAT: [16, 0], PVR: [16, 34], USB: [16, 18], NET: [16, 11] },
|
||||
2: { STB: [23, 8], AV: [23, 9], DAB: [23, 16], FM: [23, 14], BD: [23, 7], GAME: [23, 11], CD: [23, 6], AUX: [23, 13], PVR: [23, 15], USB: [23, 18], NET: [23, 19], FOLLOW_ZONE_1: [16, 20] },
|
||||
},
|
||||
API860_SERIES: {
|
||||
1: { STB: [16, 100], AV: [16, 94], DAB: [16, 72], FM: [16, 28], BD: [16, 98], GAME: [16, 97], VCR: [16, 119], CD: [16, 118], AUX: [16, 99], DISPLAY: [16, 58], SAT: [16, 27], PVR: [16, 96], USB: [16, 93], NET: [16, 92] },
|
||||
2: { STB: [23, 8], AV: [23, 9], DAB: [23, 16], FM: [23, 14], BD: [23, 7], GAME: [23, 11], CD: [23, 6], AUX: [23, 13], PVR: [23, 15], USB: [23, 18], NET: [23, 19], SAT: [23, 20], VCR: [23, 21], FOLLOW_ZONE_1: [16, 20] },
|
||||
},
|
||||
APIHDA_SERIES: {
|
||||
1: { STB: [16, 100], AV: [16, 94], DAB: [16, 72], FM: [16, 28], BD: [16, 98], GAME: [16, 97], UHD: [16, 125], CD: [16, 118], AUX: [16, 99], DISPLAY: [16, 58], SAT: [16, 27], PVR: [16, 96], NET: [16, 92], BT: [16, 122] },
|
||||
2: { STB: [23, 8], AV: [23, 9], DAB: [23, 16], FM: [23, 14], BD: [23, 7], GAME: [23, 11], CD: [23, 6], AUX: [23, 13], PVR: [23, 15], USB: [23, 18], NET: [23, 19], SAT: [23, 20], UHD: [23, 23], BT: [23, 22], FOLLOW_ZONE_1: [16, 20] },
|
||||
},
|
||||
APISA_SERIES: {
|
||||
1: { PHONO: [16, 117], CD: [16, 118], BD: [16, 98], SAT: [16, 27], PVR: [16, 96], AV: [16, 94], AUX: [16, 99], STB: [16, 100], NET: [16, 92], USB: [16, 93], GAME: [16, 97], ARC_ERC: [16, 125] },
|
||||
2: { PHONO: [16, 117], CD: [16, 118], BD: [16, 98], SAT: [16, 27], PVR: [16, 96], AV: [16, 94], AUX: [16, 99], STB: [16, 100], NET: [16, 92], USB: [16, 93], GAME: [16, 97], ARC_ERC: [16, 125] },
|
||||
},
|
||||
APIPA_SERIES: {},
|
||||
APIST_SERIES: {
|
||||
1: { DIG1: [21, 94], DIG2: [21, 98], DIG3: [21, 27], DIG4: [21, 97], USB: [21, 93], NET: [21, 92] },
|
||||
},
|
||||
};
|
||||
|
||||
const rc5PowerMaps: Record<TArcamFmjApiModel, Partial<Record<number, Record<'true' | 'false', number[]>>>> = {
|
||||
API450_SERIES: { 1: { true: [16, 123], false: [16, 124] }, 2: { true: [23, 123], false: [23, 124] } },
|
||||
API860_SERIES: { 1: { true: [16, 123], false: [16, 124] }, 2: { true: [23, 123], false: [23, 124] } },
|
||||
APIHDA_SERIES: { 1: { true: [16, 123], false: [16, 124] }, 2: { true: [23, 123], false: [23, 124] } },
|
||||
APISA_SERIES: { 1: { true: [16, 123], false: [16, 124] }, 2: { true: [16, 123], false: [16, 124] } },
|
||||
APIPA_SERIES: {},
|
||||
APIST_SERIES: {},
|
||||
};
|
||||
|
||||
const rc5MuteMaps: Record<TArcamFmjApiModel, Partial<Record<number, Record<'true' | 'false', number[]>>>> = {
|
||||
API450_SERIES: { 1: { true: [16, 119], false: [16, 120] }, 2: { true: [23, 4], false: [23, 5] } },
|
||||
API860_SERIES: { 1: { true: [16, 26], false: [16, 120] }, 2: { true: [23, 4], false: [23, 5] } },
|
||||
APIHDA_SERIES: { 1: { true: [16, 26], false: [16, 120] }, 2: { true: [23, 4], false: [23, 5] } },
|
||||
APISA_SERIES: { 1: { true: [16, 26], false: [16, 120] }, 2: { true: [16, 26], false: [16, 120] } },
|
||||
APIPA_SERIES: {},
|
||||
APIST_SERIES: {},
|
||||
};
|
||||
|
||||
const rc5VolumeMaps: Record<TArcamFmjApiModel, Partial<Record<number, Record<'true' | 'false', number[]>>>> = {
|
||||
API450_SERIES: { 1: { true: [16, 16], false: [16, 17] }, 2: { true: [23, 1], false: [23, 2] } },
|
||||
API860_SERIES: { 1: { true: [16, 16], false: [16, 17] }, 2: { true: [23, 1], false: [23, 2] } },
|
||||
APIHDA_SERIES: { 1: { true: [16, 16], false: [16, 17] }, 2: { true: [23, 1], false: [23, 2] } },
|
||||
APISA_SERIES: { 1: { true: [16, 16], false: [16, 17] }, 2: { true: [16, 16], false: [16, 17] } },
|
||||
APIPA_SERIES: {},
|
||||
APIST_SERIES: { 1: { true: [21, 86], false: [21, 85] } },
|
||||
};
|
||||
|
||||
const incomingAudioFormats: Record<number, string> = {
|
||||
0x00: 'PCM',
|
||||
0x01: 'ANALOGUE_DIRECT',
|
||||
0x02: 'DOLBY_DIGITAL',
|
||||
0x03: 'DOLBY_DIGITAL_EX',
|
||||
0x04: 'DOLBY_DIGITAL_SURROUND',
|
||||
0x05: 'DOLBY_DIGITAL_PLUS',
|
||||
0x06: 'DOLBY_DIGITAL_TRUE_HD',
|
||||
0x07: 'DTS',
|
||||
0x08: 'DTS_96_24',
|
||||
0x0d: 'DTS_HD_MASTER_AUDIO',
|
||||
0x0e: 'DTS_HD_HIGH_RES_AUDIO',
|
||||
0x14: 'UNSUPPORTED',
|
||||
0x15: 'UNDETECTED',
|
||||
0x16: 'DOLBY_ATMOS',
|
||||
0x17: 'DTS_X',
|
||||
0x18: 'IMAX_ENHANCED',
|
||||
0x19: 'AURO_3D',
|
||||
};
|
||||
|
||||
const incomingAudioConfigs: Record<number, string> = {
|
||||
0x00: 'DUAL_MONO',
|
||||
0x01: 'MONO',
|
||||
0x02: 'STEREO_ONLY',
|
||||
0x08: 'STEREO_CENTER',
|
||||
0x0e: 'STEREO_DOWNMIX',
|
||||
0x20: 'UNKNOWN',
|
||||
0x21: 'UNDETECTED',
|
||||
};
|
||||
|
||||
const incomingAudioSampleRates: Record<number, number> = {
|
||||
0x00: 32000,
|
||||
0x01: 44100,
|
||||
0x02: 48000,
|
||||
0x03: 88200,
|
||||
0x04: 96000,
|
||||
0x05: 176400,
|
||||
0x06: 192000,
|
||||
};
|
||||
|
||||
const videoAspectRatios: Record<number, string> = {
|
||||
0x00: 'UNDEFINED',
|
||||
0x01: 'ASPECT_4_3',
|
||||
0x02: 'ASPECT_16_9',
|
||||
};
|
||||
|
||||
const videoColorspaces: Record<number, string> = {
|
||||
0x00: 'NORMAL',
|
||||
0x01: 'HDR10',
|
||||
0x02: 'DOLBY_VISION',
|
||||
0x03: 'HLG',
|
||||
0x04: 'HDR10_PLUS',
|
||||
};
|
||||
|
||||
export class ArcamFmjClient {
|
||||
constructor(private readonly config: IArcamFmjConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<IArcamFmjSnapshot> {
|
||||
if (this.config.snapshot) {
|
||||
return this.normalizeSnapshot(this.cloneValue(this.config.snapshot), 'snapshot');
|
||||
}
|
||||
|
||||
if (!this.config.host) {
|
||||
return this.normalizeSnapshot({
|
||||
deviceInfo: this.deviceInfoFromConfig(),
|
||||
zones: this.config.zones?.length ? this.config.zones : [this.manualZone(1, false)],
|
||||
online: false,
|
||||
source: 'manual',
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}, 'manual');
|
||||
}
|
||||
|
||||
const amx = await this.requestAmx().catch(() => undefined);
|
||||
const deviceInfo = this.deviceInfoFromConfig(amx);
|
||||
const zoneNumbers = this.config.zones?.length ? this.config.zones.map((zoneArg) => zoneArg.zone) : [1, 2];
|
||||
const zones = await Promise.all(zoneNumbers.map((zoneArg) => this.getZoneState(zoneArg, deviceInfo).catch(() => this.manualZone(zoneArg, false))));
|
||||
|
||||
return this.normalizeSnapshot({
|
||||
deviceInfo,
|
||||
zones,
|
||||
online: zones.some((zoneArg) => zoneArg.available !== false),
|
||||
source: 'tcp',
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}, 'tcp');
|
||||
}
|
||||
|
||||
public async execute(requestArg: IArcamFmjCommandRequest): Promise<IArcamFmjCommandResult> {
|
||||
const modeledCommand = this.modelCommand(requestArg);
|
||||
if (this.config.commandExecutor) {
|
||||
return {
|
||||
transport: 'executor',
|
||||
modeledCommand,
|
||||
executorResult: await this.config.commandExecutor.execute(modeledCommand),
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.config.host) {
|
||||
throw new Error('Arcam FMJ commands require config.host or commandExecutor.');
|
||||
}
|
||||
|
||||
const response = modeledCommand.responseExpected ? await this.requestPacket(modeledCommand) : undefined;
|
||||
if (response && response.answerCode !== 0x00) {
|
||||
throw new Error(`Arcam FMJ command ${modeledCommand.commandCodeName} failed with answer code 0x${response.answerCode.toString(16)}.`);
|
||||
}
|
||||
if (!modeledCommand.responseExpected) {
|
||||
await this.sendPacket(modeledCommand);
|
||||
}
|
||||
return { transport: 'tcp', modeledCommand, response };
|
||||
}
|
||||
|
||||
public modelCommand(requestArg: IArcamFmjCommandRequest): IArcamFmjModeledCommand {
|
||||
const zone = this.normalizeZone(requestArg.zone);
|
||||
const apiModel = this.apiModel();
|
||||
|
||||
if (requestArg.command === 'raw_command') {
|
||||
if (typeof requestArg.commandCode !== 'number') {
|
||||
throw new Error('Arcam FMJ raw_command requires commandCode.');
|
||||
}
|
||||
return this.modeled(requestArg, zone, apiModel, requestArg.commandCode, requestArg.data || [], Boolean(requestArg.sendOnly), false);
|
||||
}
|
||||
|
||||
if (requestArg.command === 'turn_on' || requestArg.command === 'turn_off') {
|
||||
const power = requestArg.command === 'turn_on';
|
||||
if (directPowerWriteSupported.has(apiModel)) {
|
||||
return this.modeled(requestArg, zone, apiModel, commandCodes.POWER, [power ? 0x01 : 0x00], false, false);
|
||||
}
|
||||
return this.modeled(requestArg, zone, apiModel, commandCodes.SIMULATE_RC5_IR_COMMAND, this.lookupRc5(rc5PowerMaps, apiModel, zone, power), !power, true);
|
||||
}
|
||||
|
||||
if (requestArg.command === 'volume_up' || requestArg.command === 'volume_down') {
|
||||
const up = requestArg.command === 'volume_up';
|
||||
if (volumeStepSupported.has(apiModel)) {
|
||||
return this.modeled(requestArg, zone, apiModel, commandCodes.VOLUME, [up ? 0xf1 : 0xf2], false, false);
|
||||
}
|
||||
return this.modeled(requestArg, zone, apiModel, commandCodes.SIMULATE_RC5_IR_COMMAND, this.lookupRc5(rc5VolumeMaps, apiModel, zone, up), false, true);
|
||||
}
|
||||
|
||||
if (requestArg.command === 'set_volume') {
|
||||
const rawVolume = typeof requestArg.volume === 'number' ? requestArg.volume : Math.round((requestArg.volumeLevel ?? 0) * 99);
|
||||
return this.modeled(requestArg, zone, apiModel, commandCodes.VOLUME, [clamp(Math.round(rawVolume), 0, 99)], false, false);
|
||||
}
|
||||
|
||||
if (requestArg.command === 'mute') {
|
||||
const muted = Boolean(requestArg.muted);
|
||||
if (directMuteWriteSupported.has(apiModel)) {
|
||||
return this.modeled(requestArg, zone, apiModel, commandCodes.MUTE, [muted ? 0x00 : 0x01], false, false);
|
||||
}
|
||||
return this.modeled(requestArg, zone, apiModel, commandCodes.SIMULATE_RC5_IR_COMMAND, this.lookupRc5(rc5MuteMaps, apiModel, zone, muted), false, true);
|
||||
}
|
||||
|
||||
if (requestArg.command === 'select_source') {
|
||||
if (!requestArg.source) {
|
||||
throw new Error('Arcam FMJ select_source requires source.');
|
||||
}
|
||||
const source = normalizeSource(requestArg.source);
|
||||
if (directSourceWriteSupported.has(apiModel)) {
|
||||
return this.modeled(requestArg, zone, apiModel, commandCodes.CURRENT_SOURCE, [this.lookupStatusSource(apiModel, zone, source)], false, false);
|
||||
}
|
||||
return this.modeled({ ...requestArg, source }, zone, apiModel, commandCodes.SIMULATE_RC5_IR_COMMAND, this.lookupRc5Source(apiModel, zone, source), false, true);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Arcam FMJ command: ${requestArg.command}`);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async getZoneState(zoneArg: number, deviceInfoArg: IArcamFmjDeviceInfo): Promise<IArcamFmjZoneState> {
|
||||
const apiModel = deviceInfoArg.apiModel || this.apiModel();
|
||||
const [power, volume, mute, source, menu, decode2ch, decodeMch, incomingVideo, incomingAudio, audioSampleRate, dabStation, dlsPdt, rds, tunerPreset] = await Promise.all([
|
||||
this.optionalStatus(zoneArg, commandCodes.POWER),
|
||||
this.optionalStatus(zoneArg, commandCodes.VOLUME),
|
||||
this.optionalStatus(zoneArg, commandCodes.MUTE),
|
||||
this.optionalStatus(zoneArg, commandCodes.CURRENT_SOURCE),
|
||||
this.optionalStatus(zoneArg, commandCodes.MENU),
|
||||
this.optionalStatus(zoneArg, commandCodes.DECODE_MODE_STATUS_2CH),
|
||||
this.optionalStatus(zoneArg, commandCodes.DECODE_MODE_STATUS_MCH),
|
||||
this.optionalStatus(zoneArg, commandCodes.INCOMING_VIDEO_PARAMETERS),
|
||||
this.optionalStatus(zoneArg, commandCodes.INCOMING_AUDIO_FORMAT),
|
||||
this.optionalStatus(zoneArg, commandCodes.INCOMING_AUDIO_SAMPLE_RATE),
|
||||
this.optionalStatus(zoneArg, commandCodes.DAB_STATION),
|
||||
this.optionalStatus(zoneArg, commandCodes.DLS_PDT_INFO),
|
||||
this.optionalStatus(zoneArg, commandCodes.RDS_INFORMATION),
|
||||
this.optionalStatus(zoneArg, commandCodes.TUNER_PRESET),
|
||||
]);
|
||||
const sourceName = source?.length ? this.sourceFromStatusByte(apiModel, zoneArg, source[0]) : undefined;
|
||||
const zone: IArcamFmjZoneState = {
|
||||
zone: zoneArg,
|
||||
name: this.zoneName(zoneArg),
|
||||
power: power?.[0] === 0x01,
|
||||
state: power?.[0] === 0x01 ? 'on' : 'off',
|
||||
volume: volume?.[0],
|
||||
muted: mute?.length ? mute[0] === 0x00 : undefined,
|
||||
source: sourceName,
|
||||
sourceList: this.sourceList(apiModel, zoneArg),
|
||||
soundMode: decodeMch?.length ? `CODE_${decodeMch[0]}` : decode2ch?.length ? `CODE_${decode2ch[0]}` : undefined,
|
||||
incomingVideo: incomingVideo ? parseIncomingVideo(incomingVideo) : undefined,
|
||||
incomingAudio: parseIncomingAudio(incomingAudio, audioSampleRate),
|
||||
dabStation: textValue(dabStation),
|
||||
dlsPdt: textValue(dlsPdt),
|
||||
rdsInformation: textValue(rds),
|
||||
tunerPreset: tunerPreset && tunerPreset[0] !== 0xff ? tunerPreset[0] : undefined,
|
||||
available: Boolean(power || volume || mute || source || menu),
|
||||
};
|
||||
zone.volumeLevel = typeof zone.volume === 'number' ? zone.volume / 99 : undefined;
|
||||
zone.media = this.mediaInfo(zone);
|
||||
return zone;
|
||||
}
|
||||
|
||||
private async optionalStatus(zoneArg: number, commandCodeArg: number): Promise<number[] | undefined> {
|
||||
try {
|
||||
const response = await this.requestPacket(this.modeled({ command: 'raw_command' }, zoneArg, this.apiModel(), commandCodeArg, [queryByte], false, false));
|
||||
return response.answerCode === 0x00 ? response.data : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async requestPacket(commandArg: IArcamFmjModeledCommand): Promise<IArcamFmjResponsePacket> {
|
||||
const response = await this.exchange(this.commandPacketBytes(commandArg), (packetArg) => {
|
||||
return packetArg.type === 'response' && packetArg.packet.zone === commandArg.zone && packetArg.packet.commandCode === commandArg.commandCode;
|
||||
});
|
||||
if (response.type !== 'response') {
|
||||
throw new Error('Arcam FMJ command returned non-command response.');
|
||||
}
|
||||
return response.packet;
|
||||
}
|
||||
|
||||
private async sendPacket(commandArg: IArcamFmjModeledCommand): Promise<void> {
|
||||
await this.exchange(this.commandPacketBytes(commandArg), () => false, false);
|
||||
}
|
||||
|
||||
private async requestAmx(): Promise<Record<string, string>> {
|
||||
const response = await this.exchange(Buffer.from('AMX\r', 'ascii'), (packetArg) => packetArg.type === 'amx');
|
||||
if (response.type !== 'amx') {
|
||||
throw new Error('Arcam FMJ AMX request returned command response.');
|
||||
}
|
||||
return response.values;
|
||||
}
|
||||
|
||||
private async exchange(
|
||||
requestArg: Buffer,
|
||||
matchesArg: (packetArg: TParsedPacket) => boolean,
|
||||
expectResponseArg = true
|
||||
): Promise<TParsedPacket> {
|
||||
if (!this.config.host) {
|
||||
throw new Error('Arcam FMJ TCP exchange requires config.host.');
|
||||
}
|
||||
const host = this.config.host;
|
||||
const port = this.config.port || arcamFmjDefaultPort;
|
||||
const timeoutMs = this.config.requestTimeoutMs || defaultTimeoutMs;
|
||||
|
||||
return new Promise<TParsedPacket>((resolve, reject) => {
|
||||
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
||||
let settled = false;
|
||||
const socket = plugins.net.createConnection({ host, port });
|
||||
const timeout = setTimeout(() => finish(new Error(`Arcam FMJ TCP exchange timed out after ${timeoutMs}ms.`)), timeoutMs);
|
||||
|
||||
const finish = (errorArg?: Error, packetArg?: TParsedPacket) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
if (errorArg) {
|
||||
reject(errorArg);
|
||||
return;
|
||||
}
|
||||
resolve(packetArg as TParsedPacket);
|
||||
};
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.write(requestArg, (errorArg) => {
|
||||
if (errorArg) {
|
||||
finish(errorArg);
|
||||
return;
|
||||
}
|
||||
if (!expectResponseArg) {
|
||||
finish(undefined, { type: 'sent' });
|
||||
}
|
||||
});
|
||||
});
|
||||
socket.on('data', (chunkArg) => {
|
||||
const chunk = typeof chunkArg === 'string' ? Buffer.from(chunkArg) : chunkArg;
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
while (true) {
|
||||
const parsed = parsePacket(buffer);
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
buffer = parsed.remaining;
|
||||
if (matchesArg(parsed.packet)) {
|
||||
finish(undefined, parsed.packet);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on('error', (errorArg) => finish(errorArg));
|
||||
socket.on('close', () => finish(new Error('Arcam FMJ TCP connection closed before exchange completed.')));
|
||||
});
|
||||
}
|
||||
|
||||
private commandPacketBytes(commandArg: IArcamFmjModeledCommand): Buffer {
|
||||
return Buffer.from([protocolStart, commandArg.zone, commandArg.commandCode, commandArg.data.length, ...commandArg.data, protocolEnd]);
|
||||
}
|
||||
|
||||
private modeled(
|
||||
requestArg: IArcamFmjCommandRequest,
|
||||
zoneArg: number,
|
||||
apiModelArg: TArcamFmjApiModel,
|
||||
commandCodeArg: number,
|
||||
dataArg: number[],
|
||||
sendOnlyArg: boolean,
|
||||
usesRc5Arg: boolean
|
||||
): IArcamFmjModeledCommand {
|
||||
const data = dataArg.map((valueArg) => clamp(Math.round(valueArg), 0, 255));
|
||||
return {
|
||||
command: requestArg.command,
|
||||
zone: zoneArg,
|
||||
apiModel: apiModelArg,
|
||||
commandCode: commandCodeArg,
|
||||
commandCodeName: commandNames[commandCodeArg] || `CODE_${commandCodeArg}`,
|
||||
data,
|
||||
dataHex: hex(data),
|
||||
sendOnly: sendOnlyArg,
|
||||
responseExpected: !sendOnlyArg,
|
||||
usesRc5: usesRc5Arg,
|
||||
source: requestArg.source,
|
||||
volumeLevel: requestArg.volumeLevel,
|
||||
muted: requestArg.muted,
|
||||
};
|
||||
}
|
||||
|
||||
private lookupRc5(
|
||||
mapsArg: Record<TArcamFmjApiModel, Partial<Record<number, Record<'true' | 'false', number[]>>>>,
|
||||
apiModelArg: TArcamFmjApiModel,
|
||||
zoneArg: number,
|
||||
valueArg: boolean
|
||||
): number[] {
|
||||
const command = mapsArg[apiModelArg]?.[zoneArg]?.[valueArg ? 'true' : 'false'];
|
||||
if (!command) {
|
||||
throw new Error(`Arcam FMJ ${apiModelArg} zone ${zoneArg} does not support this RC5 command.`);
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
private lookupRc5Source(apiModelArg: TArcamFmjApiModel, zoneArg: number, sourceArg: string): number[] {
|
||||
const command = rc5SourceMaps[apiModelArg]?.[zoneArg]?.[sourceArg];
|
||||
if (!command) {
|
||||
throw new Error(`Arcam FMJ ${apiModelArg} zone ${zoneArg} does not support source ${sourceArg}.`);
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
private lookupStatusSource(apiModelArg: TArcamFmjApiModel, zoneArg: number, sourceArg: string): number {
|
||||
const value = statusSourceMaps[apiModelArg]?.[zoneArg]?.[sourceArg];
|
||||
if (typeof value !== 'number') {
|
||||
throw new Error(`Arcam FMJ ${apiModelArg} zone ${zoneArg} does not support source ${sourceArg}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private sourceFromStatusByte(apiModelArg: TArcamFmjApiModel, zoneArg: number, valueArg: number): TArcamFmjSource | undefined {
|
||||
const map = statusSourceMaps[apiModelArg]?.[zoneArg];
|
||||
if (!map) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.entries(map).find((entryArg) => entryArg[1] === valueArg)?.[0] as TArcamFmjSource | undefined;
|
||||
}
|
||||
|
||||
private sourceList(apiModelArg: TArcamFmjApiModel, zoneArg: number): TArcamFmjSource[] | undefined {
|
||||
const sourceMap = rc5SourceMaps[apiModelArg]?.[zoneArg] || statusSourceMaps[apiModelArg]?.[zoneArg];
|
||||
return sourceMap ? Object.keys(sourceMap) as TArcamFmjSource[] : undefined;
|
||||
}
|
||||
|
||||
private mediaInfo(zoneArg: IArcamFmjZoneState) {
|
||||
if (zoneArg.source === 'DAB') {
|
||||
return {
|
||||
title: zoneArg.dabStation ? `DAB - ${zoneArg.dabStation}` : 'DAB',
|
||||
artist: zoneArg.dlsPdt,
|
||||
channel: zoneArg.dabStation,
|
||||
contentType: 'music',
|
||||
contentId: zoneArg.tunerPreset ? `preset:${zoneArg.tunerPreset}` : undefined,
|
||||
};
|
||||
}
|
||||
if (zoneArg.source === 'FM') {
|
||||
return {
|
||||
title: zoneArg.rdsInformation ? `FM - ${zoneArg.rdsInformation}` : 'FM',
|
||||
channel: zoneArg.rdsInformation,
|
||||
contentType: 'music',
|
||||
contentId: zoneArg.tunerPreset ? `preset:${zoneArg.tunerPreset}` : undefined,
|
||||
};
|
||||
}
|
||||
return zoneArg.source ? { title: zoneArg.source } : undefined;
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IArcamFmjSnapshot, sourceArg: TArcamFmjSnapshotSource): IArcamFmjSnapshot {
|
||||
const deviceInfo = {
|
||||
...this.deviceInfoFromConfig(),
|
||||
...snapshotArg.deviceInfo,
|
||||
};
|
||||
const apiModel = deviceInfo.apiModel || this.apiModel(deviceInfo.model);
|
||||
deviceInfo.apiModel = apiModel;
|
||||
const online = snapshotArg.online ?? snapshotArg.zones.some((zoneArg) => zoneArg.available !== false);
|
||||
const zones = snapshotArg.zones.map((zoneArg) => {
|
||||
const zone: IArcamFmjZoneState = {
|
||||
...zoneArg,
|
||||
name: zoneArg.name || this.zoneName(zoneArg.zone),
|
||||
available: zoneArg.available ?? online,
|
||||
};
|
||||
zone.sourceList = zone.sourceList || this.sourceList(apiModel, zone.zone);
|
||||
zone.volumeLevel = typeof zone.volumeLevel === 'number'
|
||||
? clamp(zone.volumeLevel, 0, 1)
|
||||
: typeof zone.volume === 'number'
|
||||
? clamp(zone.volume / 99, 0, 1)
|
||||
: undefined;
|
||||
zone.state = zone.state || (zone.power === false ? 'off' : zone.power === true ? 'on' : 'unknown');
|
||||
zone.media = zone.media || this.mediaInfo(zone);
|
||||
return zone;
|
||||
});
|
||||
return {
|
||||
...snapshotArg,
|
||||
deviceInfo,
|
||||
zones,
|
||||
online,
|
||||
source: snapshotArg.source || sourceArg,
|
||||
lastUpdated: snapshotArg.lastUpdated || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private deviceInfoFromConfig(amxArg?: Record<string, string>): IArcamFmjDeviceInfo {
|
||||
const model = amxArg?.['Device-Model'] || this.config.model;
|
||||
return {
|
||||
host: this.config.host,
|
||||
port: this.config.port || arcamFmjDefaultPort,
|
||||
name: this.config.name || (this.config.host ? `Arcam FMJ (${this.config.host})` : 'Arcam FMJ'),
|
||||
manufacturer: amxArg?.['Device-Make'] || this.config.manufacturer || 'Arcam',
|
||||
model: model || 'Arcam FMJ AVR',
|
||||
revision: amxArg?.['Device-Revision'] || this.config.revision,
|
||||
serialNumber: this.config.serialNumber,
|
||||
uniqueId: this.config.uniqueId || this.config.serialNumber || this.config.host,
|
||||
apiModel: this.apiModel(model),
|
||||
amx: amxArg,
|
||||
};
|
||||
}
|
||||
|
||||
private manualZone(zoneArg: number, availableArg: boolean): IArcamFmjZoneState {
|
||||
const configured = this.config.zones?.find((itemArg) => itemArg.zone === zoneArg);
|
||||
return {
|
||||
zone: zoneArg,
|
||||
name: this.zoneName(zoneArg),
|
||||
power: false,
|
||||
state: 'off',
|
||||
available: availableArg,
|
||||
sourceList: this.sourceList(this.apiModel(), zoneArg),
|
||||
...configured,
|
||||
};
|
||||
}
|
||||
|
||||
private apiModel(modelArg = this.config.model): TArcamFmjApiModel {
|
||||
if (this.config.apiModel) {
|
||||
return this.config.apiModel;
|
||||
}
|
||||
const model = modelArg?.toUpperCase();
|
||||
const match = model ? modelSeries.find((seriesArg) => seriesArg.models.includes(model)) : undefined;
|
||||
return match?.apiModel || 'API450_SERIES';
|
||||
}
|
||||
|
||||
private normalizeZone(zoneArg: number | undefined): number {
|
||||
return zoneArg && Number.isFinite(zoneArg) && zoneArg > 0 ? Math.round(zoneArg) : 1;
|
||||
}
|
||||
|
||||
private zoneName(zoneArg: number): string {
|
||||
const configured = this.config.zones?.find((itemArg) => itemArg.zone === zoneArg)?.name;
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
return zoneArg === 1 ? 'Main Zone' : `Zone ${zoneArg}`;
|
||||
}
|
||||
|
||||
private cloneValue<TValue>(valueArg: TValue): TValue {
|
||||
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
|
||||
}
|
||||
}
|
||||
|
||||
type TParsedPacket =
|
||||
| { type: 'response'; packet: IArcamFmjResponsePacket }
|
||||
| { type: 'amx'; values: Record<string, string> }
|
||||
| { type: 'sent' };
|
||||
|
||||
const parsePacket = (bufferArg: Buffer): { packet: TParsedPacket; remaining: Buffer } | undefined => {
|
||||
let buffer = bufferArg;
|
||||
while (buffer[0] === 0x00) {
|
||||
buffer = buffer.subarray(1);
|
||||
}
|
||||
if (!buffer.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (buffer[0] === protocolStart) {
|
||||
if (buffer.length < 6) {
|
||||
return undefined;
|
||||
}
|
||||
const dataLength = buffer[4];
|
||||
const packetLength = 6 + dataLength;
|
||||
if (buffer.length < packetLength) {
|
||||
return undefined;
|
||||
}
|
||||
if (buffer[packetLength - 1] !== protocolEnd) {
|
||||
return { packet: { type: 'sent' }, remaining: buffer.subarray(1) };
|
||||
}
|
||||
const data = [...buffer.subarray(5, 5 + dataLength)];
|
||||
return {
|
||||
packet: {
|
||||
type: 'response',
|
||||
packet: {
|
||||
zone: buffer[1],
|
||||
commandCode: buffer[2],
|
||||
commandCodeName: commandNames[buffer[2]] || `CODE_${buffer[2]}`,
|
||||
answerCode: buffer[3],
|
||||
data,
|
||||
dataHex: hex(data),
|
||||
},
|
||||
},
|
||||
remaining: buffer.subarray(packetLength),
|
||||
};
|
||||
}
|
||||
|
||||
if (buffer[0] === 0x01) {
|
||||
if (buffer.length < 5) {
|
||||
return undefined;
|
||||
}
|
||||
if (buffer.subarray(1, 5).toString('ascii') !== '^AMX') {
|
||||
return { packet: { type: 'sent' }, remaining: buffer.subarray(1) };
|
||||
}
|
||||
const end = buffer.indexOf(protocolEnd, 5);
|
||||
if (end < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const amx = Buffer.concat([Buffer.from('AMX', 'ascii'), buffer.subarray(5, end + 1)]);
|
||||
return { packet: { type: 'amx', values: parseAmxValues(amx) }, remaining: buffer.subarray(end + 1) };
|
||||
}
|
||||
|
||||
if (buffer.subarray(0, 3).toString('ascii') === 'AMX') {
|
||||
const end = buffer.indexOf(protocolEnd, 3);
|
||||
if (end < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return { packet: { type: 'amx', values: parseAmxValues(buffer.subarray(0, end + 1)) }, remaining: buffer.subarray(end + 1) };
|
||||
}
|
||||
|
||||
return { packet: { type: 'sent' }, remaining: buffer.subarray(1) };
|
||||
};
|
||||
|
||||
const parseAmxValues = (bufferArg: Buffer): Record<string, string> => {
|
||||
const text = bufferArg.toString('ascii');
|
||||
if (!text.startsWith('AMXB')) {
|
||||
return {};
|
||||
}
|
||||
const values: Record<string, string> = {};
|
||||
for (const match of text.slice(4).matchAll(/<(.+?)=(.+?)>/g)) {
|
||||
values[match[1]] = match[2];
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const parseIncomingVideo = (dataArg: number[]): IArcamFmjIncomingVideoParameters | undefined => {
|
||||
if (dataArg.length < 7) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
horizontalResolution: dataArg[0] * 256 + dataArg[1],
|
||||
verticalResolution: dataArg[2] * 256 + dataArg[3],
|
||||
refreshRate: dataArg[4],
|
||||
interlaced: dataArg[5] === 0x01,
|
||||
aspectRatio: videoAspectRatios[dataArg[6]] || `CODE_${dataArg[6]}`,
|
||||
colorspace: dataArg.length >= 8 ? videoColorspaces[dataArg[7]] || `CODE_${dataArg[7]}` : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const parseIncomingAudio = (formatArg: number[] | undefined, sampleRateArg: number[] | undefined): IArcamFmjIncomingAudioInfo | undefined => {
|
||||
if (!formatArg?.length && !sampleRateArg?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
format: formatArg?.length ? incomingAudioFormats[formatArg[0]] || `CODE_${formatArg[0]}` : undefined,
|
||||
config: formatArg && formatArg.length > 1 ? incomingAudioConfigs[formatArg[1]] || `CODE_${formatArg[1]}` : undefined,
|
||||
sampleRate: sampleRateArg?.length ? incomingAudioSampleRates[sampleRateArg[0]] || 0 : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSource = (sourceArg: string): string => {
|
||||
const source = sourceArg.trim().toUpperCase().replace(/[\s/-]+/g, '_').replace(/^ARC_EARC$/, 'ARC_ERC');
|
||||
const aliases: Record<string, string> = {
|
||||
ARC: 'ARC_ERC',
|
||||
EARC: 'ARC_ERC',
|
||||
ARC_ERC: 'ARC_ERC',
|
||||
BLUETOOTH: 'BT',
|
||||
NETUSB: 'NET_USB',
|
||||
NET_USB: 'NET_USB',
|
||||
};
|
||||
return aliases[source] || source;
|
||||
};
|
||||
|
||||
const textValue = (dataArg: number[] | undefined): string | undefined => {
|
||||
if (!dataArg?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return Buffer.from(dataArg).toString('utf8').trim() || undefined;
|
||||
};
|
||||
|
||||
const clamp = (valueArg: number, minArg: number, maxArg: number): number => Math.max(minArg, Math.min(maxArg, valueArg));
|
||||
|
||||
const hex = (dataArg: number[]): string => dataArg.map((valueArg) => valueArg.toString(16).padStart(2, '0')).join('');
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IArcamFmjConfig } from './arcam_fmj.types.js';
|
||||
import { arcamFmjDefaultPort } from './arcam_fmj.types.js';
|
||||
|
||||
export class ArcamFmjConfigFlow implements IConfigFlow<IArcamFmjConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IArcamFmjConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Arcam FMJ Receiver',
|
||||
description: 'Configure the local Arcam FMJ TCP control endpoint.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host', type: 'text', required: true },
|
||||
{ name: 'port', label: 'TCP port', type: 'number' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
{ name: 'model', label: 'Model', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const host = stringValue(valuesArg.host) || candidateArg.host;
|
||||
if (!host) {
|
||||
return { kind: 'error', title: 'Arcam FMJ setup failed', error: 'Arcam FMJ host is required.' };
|
||||
}
|
||||
const port = numberValue(valuesArg.port) || candidateArg.port || arcamFmjDefaultPort;
|
||||
const name = stringValue(valuesArg.name) || candidateArg.name;
|
||||
const model = stringValue(valuesArg.model) || candidateArg.model;
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Arcam FMJ configured',
|
||||
config: {
|
||||
host,
|
||||
port,
|
||||
name,
|
||||
model,
|
||||
manufacturer: candidateArg.manufacturer || 'Arcam',
|
||||
serialNumber: candidateArg.serialNumber,
|
||||
uniqueId: candidateArg.id || candidateArg.serialNumber || `${host}:${port}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
};
|
||||
|
||||
const numberValue = (valueArg: unknown): number | undefined => {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) {
|
||||
return Math.round(valueArg);
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,26 +1,163 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { ArcamFmjClient } from './arcam_fmj.classes.client.js';
|
||||
import { ArcamFmjConfigFlow } from './arcam_fmj.classes.configflow.js';
|
||||
import { createArcamFmjDiscoveryDescriptor } from './arcam_fmj.discovery.js';
|
||||
import { ArcamFmjMapper } from './arcam_fmj.mapper.js';
|
||||
import type { IArcamFmjConfig, IArcamFmjCommandRequest } from './arcam_fmj.types.js';
|
||||
|
||||
export class HomeAssistantArcamFmjIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "arcam_fmj",
|
||||
displayName: "Arcam FMJ Receivers",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/arcam_fmj",
|
||||
"upstreamDomain": "arcam_fmj",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"arcam-fmj==1.8.3"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@elupus"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class ArcamFmjIntegration extends BaseIntegration<IArcamFmjConfig> {
|
||||
public readonly domain = 'arcam_fmj';
|
||||
public readonly displayName = 'Arcam FMJ Receivers';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createArcamFmjDiscoveryDescriptor();
|
||||
public readonly configFlow = new ArcamFmjConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/arcam_fmj',
|
||||
upstreamDomain: 'arcam_fmj',
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['arcam-fmj==1.8.3'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@elupus'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/arcam_fmj',
|
||||
};
|
||||
|
||||
public async setup(configArg: IArcamFmjConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new ArcamFmjRuntime(new ArcamFmjClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantArcamFmjIntegration extends ArcamFmjIntegration {}
|
||||
|
||||
class ArcamFmjRuntime implements IIntegrationRuntime {
|
||||
public domain = 'arcam_fmj';
|
||||
|
||||
constructor(private readonly client: ArcamFmjClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return ArcamFmjMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return ArcamFmjMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.domain !== 'media_player') {
|
||||
return { success: false, error: `Unsupported Arcam FMJ service domain: ${requestArg.domain}` };
|
||||
}
|
||||
|
||||
try {
|
||||
const command = await this.commandFromService(requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported Arcam FMJ media_player service: ${requestArg.service}` };
|
||||
}
|
||||
return { success: true, data: await this.client.execute(command) };
|
||||
} catch (errorArg) {
|
||||
return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) };
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private async commandFromService(requestArg: IServiceCallRequest): Promise<IArcamFmjCommandRequest | undefined> {
|
||||
const zone = await this.zoneFromRequest(requestArg);
|
||||
if (requestArg.service === 'turn_on') {
|
||||
return { command: 'turn_on', zone };
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return { command: 'turn_off', zone };
|
||||
}
|
||||
if (requestArg.service === 'volume_up') {
|
||||
return { command: 'volume_up', zone };
|
||||
}
|
||||
if (requestArg.service === 'volume_down') {
|
||||
return { command: 'volume_down', zone };
|
||||
}
|
||||
if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume') {
|
||||
const volumeLevel = this.numberData(requestArg, 'volume_level') ?? this.numberData(requestArg, 'volumeLevel');
|
||||
const volume = this.numberData(requestArg, 'volume');
|
||||
if (typeof volumeLevel !== 'number' && typeof volume !== 'number') {
|
||||
throw new Error('Arcam FMJ volume_set requires data.volume_level or data.volume.');
|
||||
}
|
||||
return { command: 'set_volume', zone, volumeLevel, volume };
|
||||
}
|
||||
if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') {
|
||||
const muted = this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'mute') ?? this.boolData(requestArg, 'muted');
|
||||
if (typeof muted !== 'boolean') {
|
||||
throw new Error('Arcam FMJ volume_mute requires data.is_volume_muted.');
|
||||
}
|
||||
return { command: 'mute', zone, muted };
|
||||
}
|
||||
if (requestArg.service === 'select_source') {
|
||||
const source = this.stringData(requestArg, 'source');
|
||||
if (!source) {
|
||||
throw new Error('Arcam FMJ select_source requires data.source.');
|
||||
}
|
||||
return { command: 'select_source', zone, source };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async zoneFromRequest(requestArg: IServiceCallRequest): Promise<number> {
|
||||
const explicitZone = this.numberData(requestArg, 'zone') ?? numberFromString(this.stringData(requestArg, 'zone'));
|
||||
if (explicitZone) {
|
||||
return explicitZone;
|
||||
}
|
||||
|
||||
const entityId = requestArg.target.entityId;
|
||||
if (entityId) {
|
||||
const snapshot = await this.client.getSnapshot().catch(() => undefined);
|
||||
const entity = snapshot ? ArcamFmjMapper.toEntities(snapshot).find((entityArg) => entityArg.id === entityId) : undefined;
|
||||
const zone = entity?.attributes?.zone;
|
||||
if (typeof zone === 'number') {
|
||||
return zone;
|
||||
}
|
||||
const match = /zone[_-]?(\d+)/i.exec(entityId);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private stringData(requestArg: IServiceCallRequest, keyArg: string): string | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'string' && value ? value : undefined;
|
||||
}
|
||||
|
||||
private numberData(requestArg: IServiceCallRequest, keyArg: string): number | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private boolData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined {
|
||||
const value = requestArg.data?.[keyArg];
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const numberFromString = (valueArg: string | undefined): number | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
const match = valueArg.match(/\d+/);
|
||||
return match ? Number(match[0]) : undefined;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { IArcamFmjManualEntry, IArcamFmjSsdpRecord } from './arcam_fmj.types.js';
|
||||
import { arcamFmjDefaultPort } from './arcam_fmj.types.js';
|
||||
|
||||
const domain = 'arcam_fmj';
|
||||
|
||||
export class ArcamFmjSsdpMatcher implements IDiscoveryMatcher<IArcamFmjSsdpRecord> {
|
||||
public id = 'arcam-fmj-ssdp-match';
|
||||
public source = 'ssdp' as const;
|
||||
public description = 'Recognize Arcam FMJ SSDP media renderer advertisements.';
|
||||
|
||||
public async matches(recordArg: IArcamFmjSsdpRecord): Promise<IDiscoveryMatch> {
|
||||
const st = header(recordArg, 'st') || upnp(recordArg, 'deviceType');
|
||||
const usn = header(recordArg, 'usn');
|
||||
const location = header(recordArg, 'location');
|
||||
const manufacturer = upnp(recordArg, 'manufacturer');
|
||||
const model = upnp(recordArg, 'modelName') || upnp(recordArg, 'model');
|
||||
const friendlyName = upnp(recordArg, 'friendlyName');
|
||||
const udn = upnp(recordArg, 'UDN') || upnp(recordArg, 'udn') || usn;
|
||||
const deviceType = upnp(recordArg, 'deviceType') || st;
|
||||
const haystack = `${manufacturer || ''} ${model || ''} ${friendlyName || ''} ${deviceType || ''} ${usn || ''}`.toLowerCase();
|
||||
const matched = haystack.includes('arcam') || (haystack.includes('mediarenderer') && manufacturer?.toLowerCase() === 'arcam');
|
||||
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'SSDP record is not an Arcam FMJ receiver.' };
|
||||
}
|
||||
|
||||
const url = parseUrl(location);
|
||||
const id = uniqueIdFromUdn(udn) || stripUuid(usn);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: id ? 'certain' : 'high',
|
||||
reason: 'SSDP record matches Arcam FMJ metadata.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'ssdp',
|
||||
integrationDomain: domain,
|
||||
id,
|
||||
host: url?.hostname,
|
||||
port: arcamFmjDefaultPort,
|
||||
name: friendlyName,
|
||||
manufacturer: manufacturer || 'Arcam',
|
||||
model,
|
||||
metadata: { st, usn, location, udn, deviceType },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ArcamFmjManualMatcher implements IDiscoveryMatcher<IArcamFmjManualEntry> {
|
||||
public id = 'arcam-fmj-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Arcam FMJ receiver setup entries.';
|
||||
|
||||
public async matches(inputArg: IArcamFmjManualEntry): Promise<IDiscoveryMatch> {
|
||||
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase();
|
||||
const matched = Boolean(inputArg.host || haystack.includes('arcam') || haystack.includes('fmj') || inputArg.metadata?.arcam_fmj);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Arcam FMJ setup hints.' };
|
||||
}
|
||||
const port = inputArg.port || arcamFmjDefaultPort;
|
||||
const id = inputArg.id || inputArg.serialNumber || (inputArg.host ? `${inputArg.host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Arcam FMJ TCP setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: domain,
|
||||
id,
|
||||
host: inputArg.host,
|
||||
port,
|
||||
name: inputArg.name,
|
||||
manufacturer: inputArg.manufacturer || 'Arcam',
|
||||
model: inputArg.model,
|
||||
serialNumber: inputArg.serialNumber,
|
||||
metadata: inputArg.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ArcamFmjCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'arcam-fmj-candidate-validator';
|
||||
public description = 'Validate Arcam FMJ candidates have Arcam receiver metadata.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase();
|
||||
const matched = candidateArg.integrationDomain === domain
|
||||
|| haystack.includes('arcam')
|
||||
|| haystack.includes('fmj')
|
||||
|| Boolean(metadata.arcam_fmj);
|
||||
|
||||
return {
|
||||
matched,
|
||||
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Candidate has Arcam FMJ metadata.' : 'Candidate is not an Arcam FMJ receiver.',
|
||||
normalizedDeviceId: candidateArg.id || candidateArg.serialNumber || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || arcamFmjDefaultPort}` : undefined),
|
||||
candidate: matched ? { ...candidateArg, port: candidateArg.port || arcamFmjDefaultPort } : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createArcamFmjDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: domain, displayName: 'Arcam FMJ Receivers' })
|
||||
.addMatcher(new ArcamFmjSsdpMatcher())
|
||||
.addMatcher(new ArcamFmjManualMatcher())
|
||||
.addValidator(new ArcamFmjCandidateValidator());
|
||||
};
|
||||
|
||||
const header = (recordArg: IArcamFmjSsdpRecord, keyArg: string): string | undefined => {
|
||||
return recordArg[keyArg as keyof IArcamFmjSsdpRecord] as string | undefined || valueForKey(recordArg.headers, keyArg);
|
||||
};
|
||||
|
||||
const upnp = (recordArg: IArcamFmjSsdpRecord, keyArg: string): string | undefined => {
|
||||
return valueForKey(recordArg.upnp, keyArg) || valueForKey(recordArg.headers, keyArg);
|
||||
};
|
||||
|
||||
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
|
||||
if (!recordArg) {
|
||||
return undefined;
|
||||
}
|
||||
const lowerKey = keyArg.toLowerCase();
|
||||
for (const [key, value] of Object.entries(recordArg)) {
|
||||
if (key.toLowerCase() === lowerKey) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseUrl = (valueArg: string | undefined): URL | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(valueArg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const uniqueIdFromUdn = (valueArg: string | undefined): string | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
const stripped = stripUuid(valueArg);
|
||||
const parts = stripped?.split('-') || [];
|
||||
return parts[4] || stripped;
|
||||
};
|
||||
|
||||
const stripUuid = (valueArg: string | undefined): string | undefined => {
|
||||
return valueArg?.replace(/^uuid:/i, '').split('::')[0];
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import type { IArcamFmjDeviceInfo, IArcamFmjSnapshot, IArcamFmjZoneState } from './arcam_fmj.types.js';
|
||||
|
||||
export class ArcamFmjMapper {
|
||||
public static toDevices(snapshotArg: IArcamFmjSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.lastUpdated || new Date().toISOString();
|
||||
return snapshotArg.zones.map((zoneArg) => {
|
||||
const volumeLevel = this.volumeLevel(zoneArg);
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'power', capability: 'media', name: 'Power', readable: true, writable: true },
|
||||
{ id: 'source', capability: 'media', name: 'Source', readable: true, writable: true },
|
||||
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
|
||||
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'power', value: this.powerState(zoneArg), updatedAt },
|
||||
{ featureId: 'source', value: zoneArg.source || null, updatedAt },
|
||||
{ featureId: 'volume', value: typeof volumeLevel === 'number' ? Math.round(volumeLevel * 100) : null, updatedAt },
|
||||
{ featureId: 'muted', value: typeof zoneArg.muted === 'boolean' ? zoneArg.muted : null, updatedAt },
|
||||
];
|
||||
|
||||
if (zoneArg.soundMode || zoneArg.soundModeList?.length) {
|
||||
features.push({ id: 'sound_mode', capability: 'media', name: 'Sound mode', readable: true, writable: true });
|
||||
state.push({ featureId: 'sound_mode', value: zoneArg.soundMode || null, updatedAt });
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.deviceId(snapshotArg, zoneArg),
|
||||
integrationDomain: 'arcam_fmj',
|
||||
name: this.deviceName(snapshotArg.deviceInfo, zoneArg),
|
||||
protocol: 'unknown',
|
||||
manufacturer: snapshotArg.deviceInfo.manufacturer || 'Arcam',
|
||||
model: snapshotArg.deviceInfo.model || 'Arcam FMJ AVR',
|
||||
online: zoneArg.available !== false,
|
||||
features,
|
||||
state,
|
||||
metadata: {
|
||||
host: snapshotArg.deviceInfo.host,
|
||||
port: snapshotArg.deviceInfo.port,
|
||||
zone: zoneArg.zone,
|
||||
revision: snapshotArg.deviceInfo.revision,
|
||||
serialNumber: snapshotArg.deviceInfo.serialNumber,
|
||||
uniqueId: snapshotArg.deviceInfo.uniqueId,
|
||||
apiModel: snapshotArg.deviceInfo.apiModel,
|
||||
viaDeviceId: zoneArg.zone === 1 ? undefined : this.mainDeviceId(snapshotArg),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IArcamFmjSnapshot): IIntegrationEntity[] {
|
||||
return snapshotArg.zones.map((zoneArg) => ({
|
||||
id: `media_player.${this.entityBase(snapshotArg, zoneArg)}`,
|
||||
uniqueId: `arcam_fmj_${this.uniqueBase(snapshotArg)}_${zoneArg.zone}`,
|
||||
integrationDomain: 'arcam_fmj',
|
||||
deviceId: this.deviceId(snapshotArg, zoneArg),
|
||||
platform: 'media_player',
|
||||
name: this.deviceName(snapshotArg.deviceInfo, zoneArg),
|
||||
state: this.mediaState(zoneArg),
|
||||
attributes: {
|
||||
deviceClass: 'receiver',
|
||||
zone: zoneArg.zone,
|
||||
power: zoneArg.power,
|
||||
volume: zoneArg.volume,
|
||||
volumeLevel: this.volumeLevel(zoneArg),
|
||||
isVolumeMuted: zoneArg.muted,
|
||||
source: zoneArg.source,
|
||||
sourceList: zoneArg.sourceList,
|
||||
soundMode: zoneArg.soundMode,
|
||||
soundModeList: zoneArg.soundModeList,
|
||||
mediaContentType: zoneArg.media?.contentType,
|
||||
mediaContentId: zoneArg.media?.contentId,
|
||||
mediaTitle: zoneArg.media?.title || zoneArg.source,
|
||||
mediaArtist: zoneArg.media?.artist,
|
||||
mediaChannel: zoneArg.media?.channel,
|
||||
tunerPreset: zoneArg.tunerPreset,
|
||||
presets: zoneArg.presets,
|
||||
incomingVideo: zoneArg.incomingVideo,
|
||||
incomingAudio: zoneArg.incomingAudio,
|
||||
},
|
||||
available: zoneArg.available !== false,
|
||||
}));
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IArcamFmjSnapshot, zoneArg: Pick<IArcamFmjZoneState, 'zone'>): string {
|
||||
const suffix = zoneArg.zone === 1 ? '' : `.zone_${zoneArg.zone}`;
|
||||
return `arcam_fmj.receiver.${this.uniqueBase(snapshotArg)}${suffix}`;
|
||||
}
|
||||
|
||||
public static slug(valueArg: string | undefined): string {
|
||||
return (valueArg || 'arcam_fmj').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'arcam_fmj';
|
||||
}
|
||||
|
||||
private static mediaState(zoneArg: IArcamFmjZoneState): string {
|
||||
if (zoneArg.power === false || zoneArg.state?.toLowerCase() === 'off') {
|
||||
return 'off';
|
||||
}
|
||||
if (zoneArg.power === true || zoneArg.state?.toLowerCase() === 'on') {
|
||||
return 'on';
|
||||
}
|
||||
return zoneArg.state || 'unknown';
|
||||
}
|
||||
|
||||
private static powerState(zoneArg: IArcamFmjZoneState): string {
|
||||
return zoneArg.power === false || zoneArg.state?.toLowerCase() === 'off' ? 'off' : 'on';
|
||||
}
|
||||
|
||||
private static volumeLevel(zoneArg: IArcamFmjZoneState): number | undefined {
|
||||
if (typeof zoneArg.volumeLevel === 'number') {
|
||||
return Math.max(0, Math.min(1, zoneArg.volumeLevel));
|
||||
}
|
||||
if (typeof zoneArg.volume === 'number') {
|
||||
return Math.max(0, Math.min(1, zoneArg.volume / 99));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static mainDeviceId(snapshotArg: IArcamFmjSnapshot): string {
|
||||
return this.deviceId(snapshotArg, { zone: 1 });
|
||||
}
|
||||
|
||||
private static deviceName(infoArg: IArcamFmjDeviceInfo, zoneArg: IArcamFmjZoneState): string {
|
||||
const receiverName = this.receiverName(infoArg);
|
||||
if (zoneArg.zone === 1) {
|
||||
return receiverName;
|
||||
}
|
||||
return `${receiverName} ${zoneArg.name || `Zone ${zoneArg.zone}`}`;
|
||||
}
|
||||
|
||||
private static receiverName(infoArg: IArcamFmjDeviceInfo): string {
|
||||
return infoArg.name || infoArg.model || 'Arcam FMJ';
|
||||
}
|
||||
|
||||
private static entityBase(snapshotArg: IArcamFmjSnapshot, zoneArg: IArcamFmjZoneState): string {
|
||||
const suffix = zoneArg.zone === 1 ? '' : `_zone_${zoneArg.zone}`;
|
||||
return `${this.slug(this.receiverName(snapshotArg.deviceInfo))}${suffix}`;
|
||||
}
|
||||
|
||||
private static uniqueBase(snapshotArg: IArcamFmjSnapshot): string {
|
||||
return this.slug(snapshotArg.deviceInfo.uniqueId || snapshotArg.deviceInfo.serialNumber || snapshotArg.deviceInfo.model || snapshotArg.deviceInfo.host || this.receiverName(snapshotArg.deviceInfo));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,207 @@
|
||||
export interface IHomeAssistantArcamFmjConfig {
|
||||
// TODO: replace with the TypeScript-native config for arcam_fmj.
|
||||
[key: string]: unknown;
|
||||
export const arcamFmjDefaultPort = 50000;
|
||||
|
||||
export type TArcamFmjApiModel =
|
||||
| 'API450_SERIES'
|
||||
| 'API860_SERIES'
|
||||
| 'APIHDA_SERIES'
|
||||
| 'APISA_SERIES'
|
||||
| 'APIPA_SERIES'
|
||||
| 'APIST_SERIES';
|
||||
|
||||
export type TArcamFmjSnapshotSource = 'manual' | 'snapshot' | 'tcp';
|
||||
|
||||
export type TArcamFmjCommand =
|
||||
| 'turn_on'
|
||||
| 'turn_off'
|
||||
| 'volume_up'
|
||||
| 'volume_down'
|
||||
| 'set_volume'
|
||||
| 'mute'
|
||||
| 'select_source'
|
||||
| 'raw_command';
|
||||
|
||||
export type TArcamFmjSource =
|
||||
| 'FOLLOW_ZONE_1'
|
||||
| 'CD'
|
||||
| 'BD'
|
||||
| 'AV'
|
||||
| 'SAT'
|
||||
| 'PVR'
|
||||
| 'VCR'
|
||||
| 'AUX'
|
||||
| 'DISPLAY'
|
||||
| 'FM'
|
||||
| 'DAB'
|
||||
| 'NET'
|
||||
| 'USB'
|
||||
| 'STB'
|
||||
| 'GAME'
|
||||
| 'PHONO'
|
||||
| 'ARC_ERC'
|
||||
| 'UHD'
|
||||
| 'BT'
|
||||
| 'DIG1'
|
||||
| 'DIG2'
|
||||
| 'DIG3'
|
||||
| 'DIG4'
|
||||
| 'NET_USB'
|
||||
| (string & {});
|
||||
|
||||
export interface IArcamFmjConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
revision?: string;
|
||||
serialNumber?: string;
|
||||
uniqueId?: string;
|
||||
apiModel?: TArcamFmjApiModel;
|
||||
zones?: IArcamFmjZoneState[];
|
||||
requestTimeoutMs?: number;
|
||||
sourceMap?: Record<string, string>;
|
||||
commandExecutor?: IArcamFmjCommandExecutor;
|
||||
snapshot?: IArcamFmjSnapshot;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantArcamFmjConfig extends IArcamFmjConfig {}
|
||||
|
||||
export interface IArcamFmjCommandExecutor {
|
||||
execute(commandArg: IArcamFmjModeledCommand): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface IArcamFmjDeviceInfo {
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
revision?: string;
|
||||
serialNumber?: string;
|
||||
uniqueId?: string;
|
||||
apiModel?: TArcamFmjApiModel;
|
||||
amx?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IArcamFmjIncomingVideoParameters {
|
||||
horizontalResolution?: number;
|
||||
verticalResolution?: number;
|
||||
refreshRate?: number;
|
||||
interlaced?: boolean;
|
||||
aspectRatio?: string;
|
||||
colorspace?: string;
|
||||
}
|
||||
|
||||
export interface IArcamFmjIncomingAudioInfo {
|
||||
format?: string;
|
||||
config?: string;
|
||||
sampleRate?: number;
|
||||
}
|
||||
|
||||
export interface IArcamFmjPresetDetail {
|
||||
index: number;
|
||||
type?: string | number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IArcamFmjMediaInfo {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
channel?: string;
|
||||
contentType?: string;
|
||||
contentId?: string;
|
||||
}
|
||||
|
||||
export interface IArcamFmjZoneState {
|
||||
zone: number;
|
||||
name?: string;
|
||||
power?: boolean;
|
||||
state?: 'on' | 'off' | string;
|
||||
volume?: number;
|
||||
volumeLevel?: number;
|
||||
muted?: boolean;
|
||||
source?: TArcamFmjSource;
|
||||
sourceList?: TArcamFmjSource[];
|
||||
soundMode?: string;
|
||||
soundModeList?: string[];
|
||||
incomingVideo?: IArcamFmjIncomingVideoParameters;
|
||||
incomingAudio?: IArcamFmjIncomingAudioInfo;
|
||||
dabStation?: string;
|
||||
dlsPdt?: string;
|
||||
rdsInformation?: string;
|
||||
tunerPreset?: number;
|
||||
presets?: IArcamFmjPresetDetail[];
|
||||
media?: IArcamFmjMediaInfo;
|
||||
available?: boolean;
|
||||
}
|
||||
|
||||
export interface IArcamFmjSnapshot {
|
||||
deviceInfo: IArcamFmjDeviceInfo;
|
||||
zones: IArcamFmjZoneState[];
|
||||
online?: boolean;
|
||||
source?: TArcamFmjSnapshotSource;
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
export interface IArcamFmjCommandRequest {
|
||||
command: TArcamFmjCommand;
|
||||
zone?: number;
|
||||
source?: string;
|
||||
volumeLevel?: number;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
commandCode?: number;
|
||||
data?: number[];
|
||||
sendOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface IArcamFmjModeledCommand {
|
||||
command: TArcamFmjCommand;
|
||||
zone: number;
|
||||
apiModel: TArcamFmjApiModel;
|
||||
commandCode: number;
|
||||
commandCodeName: string;
|
||||
data: number[];
|
||||
dataHex: string;
|
||||
sendOnly: boolean;
|
||||
responseExpected: boolean;
|
||||
usesRc5: boolean;
|
||||
source?: string;
|
||||
volumeLevel?: number;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
export interface IArcamFmjResponsePacket {
|
||||
zone: number;
|
||||
commandCode: number;
|
||||
commandCodeName: string;
|
||||
answerCode: number;
|
||||
data: number[];
|
||||
dataHex: string;
|
||||
}
|
||||
|
||||
export interface IArcamFmjCommandResult {
|
||||
transport: 'tcp' | 'executor';
|
||||
modeledCommand: IArcamFmjModeledCommand;
|
||||
response?: IArcamFmjResponsePacket;
|
||||
executorResult?: unknown;
|
||||
}
|
||||
|
||||
export interface IArcamFmjSsdpRecord {
|
||||
st?: string;
|
||||
usn?: string;
|
||||
location?: string;
|
||||
headers?: Record<string, string | undefined>;
|
||||
upnp?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface IArcamFmjManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './arcam_fmj.classes.client.js';
|
||||
export * from './arcam_fmj.classes.configflow.js';
|
||||
export * from './arcam_fmj.classes.integration.js';
|
||||
export * from './arcam_fmj.discovery.js';
|
||||
export * from './arcam_fmj.mapper.js';
|
||||
export * from './arcam_fmj.types.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { IAsuswrtCommand, IAsuswrtCommandResult, IAsuswrtConfig, IAsuswrtEvent, IAsuswrtSnapshot } from './asuswrt.types.js';
|
||||
import { AsuswrtMapper } from './asuswrt.mapper.js';
|
||||
|
||||
type TAsuswrtEventHandler = (eventArg: IAsuswrtEvent) => void;
|
||||
|
||||
export class AsuswrtClient {
|
||||
private currentSnapshot?: IAsuswrtSnapshot;
|
||||
private readonly eventHandlers = new Set<TAsuswrtEventHandler>();
|
||||
|
||||
constructor(private readonly config: IAsuswrtConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<IAsuswrtSnapshot> {
|
||||
if (this.config.nativeClient) {
|
||||
this.currentSnapshot = this.normalizeSnapshot(await this.config.nativeClient.getSnapshot(), 'provider');
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
if (this.config.snapshotProvider) {
|
||||
const provided = await this.config.snapshotProvider();
|
||||
if (provided) {
|
||||
this.currentSnapshot = this.normalizeSnapshot(provided, 'provider');
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.currentSnapshot) {
|
||||
this.currentSnapshot = AsuswrtMapper.toSnapshot(this.config);
|
||||
}
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
public onEvent(handlerArg: TAsuswrtEventHandler): () => void {
|
||||
this.eventHandlers.add(handlerArg);
|
||||
return () => this.eventHandlers.delete(handlerArg);
|
||||
}
|
||||
|
||||
public async refresh(): Promise<IAsuswrtCommandResult> {
|
||||
try {
|
||||
this.currentSnapshot = undefined;
|
||||
const snapshot = await this.getSnapshot();
|
||||
this.emit({ type: 'snapshot_refreshed', snapshot, timestamp: Date.now() });
|
||||
return { success: true, data: snapshot };
|
||||
} catch (errorArg) {
|
||||
const error = errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
const snapshot = AsuswrtMapper.toSnapshot({ ...this.config, connected: false, snapshot: this.currentSnapshot }, false);
|
||||
this.currentSnapshot = snapshot;
|
||||
this.emit({ type: 'refresh_failed', snapshot, error, timestamp: Date.now() });
|
||||
return { success: false, error, data: snapshot };
|
||||
}
|
||||
}
|
||||
|
||||
public async sendCommand(commandArg: IAsuswrtCommand): Promise<IAsuswrtCommandResult> {
|
||||
this.emit({ type: 'command_mapped', command: commandArg, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
|
||||
|
||||
const executor = this.config.commandExecutor || this.config.nativeClient?.executeCommand?.bind(this.config.nativeClient);
|
||||
if (!executor) {
|
||||
const result: IAsuswrtCommandResult = {
|
||||
success: false,
|
||||
error: this.unsupportedCommandMessage(commandArg),
|
||||
data: { command: commandArg },
|
||||
};
|
||||
this.emit({ type: 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = this.commandResult(await executor(commandArg), commandArg);
|
||||
this.emit({ type: result.success ? 'command_executed' : 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
|
||||
return result;
|
||||
} catch (errorArg) {
|
||||
const result: IAsuswrtCommandResult = { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg), data: { command: commandArg } };
|
||||
this.emit({ type: 'command_failed', command: commandArg, data: result, deviceId: commandArg.deviceId, entityId: commandArg.entityId, timestamp: Date.now() });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.config.nativeClient?.destroy?.();
|
||||
this.eventHandlers.clear();
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IAsuswrtSnapshot, sourceArg: IAsuswrtSnapshot['source']): IAsuswrtSnapshot {
|
||||
const normalized = AsuswrtMapper.toSnapshot({ ...this.config, snapshot: this.cloneSnapshot(snapshotArg) }, snapshotArg.connected);
|
||||
return { ...normalized, source: snapshotArg.source || sourceArg };
|
||||
}
|
||||
|
||||
private commandResult(resultArg: unknown, commandArg: IAsuswrtCommand): IAsuswrtCommandResult {
|
||||
if (this.isCommandResult(resultArg)) {
|
||||
return resultArg;
|
||||
}
|
||||
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||
}
|
||||
|
||||
private isCommandResult(valueArg: unknown): valueArg is IAsuswrtCommandResult {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg);
|
||||
}
|
||||
|
||||
private unsupportedCommandMessage(commandArg: IAsuswrtCommand): string {
|
||||
const protocol = this.config.protocol || commandArg.protocol || 'https';
|
||||
if (protocol === 'ssh' || protocol === 'telnet') {
|
||||
return 'ASUSWRT SSH/Telnet commands are not faked by this native TypeScript port. Provide commandExecutor or nativeClient.executeCommand for live reboot or connected-device actions.';
|
||||
}
|
||||
return 'ASUSWRT live commands require an injected commandExecutor or nativeClient.executeCommand; snapshot/manual mode only maps commands safely.';
|
||||
}
|
||||
|
||||
private emit(eventArg: IAsuswrtEvent): void {
|
||||
for (const handler of this.eventHandlers) {
|
||||
handler(eventArg);
|
||||
}
|
||||
}
|
||||
|
||||
private cloneSnapshot<T extends IAsuswrtSnapshot>(snapshotArg: T): T {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import { AsuswrtMapper } from './asuswrt.mapper.js';
|
||||
import type { IAsuswrtConfig, IAsuswrtSnapshot, TAsuswrtMode, TAsuswrtProtocol } from './asuswrt.types.js';
|
||||
import { asuswrtDefaultConsiderHomeSeconds, asuswrtDefaultDnsmasqPath, asuswrtDefaultInterface } from './asuswrt.types.js';
|
||||
|
||||
export class AsuswrtConfigFlow implements IConfigFlow<IAsuswrtConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAsuswrtConfig>> {
|
||||
void contextArg;
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const protocol = this.protocolValue(metadata.protocol) || 'https';
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect ASUSWRT router',
|
||||
description: 'Provide the local ASUSWRT router endpoint. Snapshot/manual data is supported directly; SSH/Telnet live success is not assumed without an injected executor or native client.',
|
||||
fields: [
|
||||
{ name: 'host', label: candidateArg.host ? `Host (${candidateArg.host})` : 'Host', type: 'text', required: true },
|
||||
{ name: 'port', label: `Port (${candidateArg.port || AsuswrtMapper.defaultPort(protocol)})`, type: 'number' },
|
||||
{ name: 'protocol', label: 'Protocol', type: 'select', options: [
|
||||
{ label: 'HTTPS', value: 'https' },
|
||||
{ label: 'HTTP', value: 'http' },
|
||||
{ label: 'SSH', value: 'ssh' },
|
||||
{ label: 'Telnet', value: 'telnet' },
|
||||
] },
|
||||
{ name: 'username', label: 'Username', type: 'text' },
|
||||
{ name: 'password', label: 'Password', type: 'password' },
|
||||
{ name: 'sshKey', label: 'SSH key path', type: 'text' },
|
||||
{ name: 'mode', label: 'Router mode', type: 'select', options: [
|
||||
{ label: 'Router', value: 'router' },
|
||||
{ label: 'Access Point', value: 'ap' },
|
||||
] },
|
||||
{ name: 'interface', label: `Interface (${asuswrtDefaultInterface})`, type: 'text' },
|
||||
{ name: 'trackUnknown', label: 'Track unknown devices', type: 'boolean' },
|
||||
{ name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
|
||||
};
|
||||
}
|
||||
|
||||
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IAsuswrtConfig>> {
|
||||
const snapshot = this.snapshotFromInput(valuesArg.snapshotJson || candidateArg.metadata?.snapshot);
|
||||
if (snapshot instanceof Error) {
|
||||
return { kind: 'error', title: 'Invalid ASUSWRT snapshot', error: snapshot.message };
|
||||
}
|
||||
|
||||
const protocol = this.protocolValue(valuesArg.protocol) || this.protocolValue(candidateArg.metadata?.protocol) || snapshot?.router.protocol || 'https';
|
||||
const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.router.host;
|
||||
if (!host && !snapshot) {
|
||||
return { kind: 'error', title: 'ASUSWRT setup failed', error: 'ASUSWRT setup requires a host or snapshot JSON.' };
|
||||
}
|
||||
|
||||
const port = this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.router.port || (host ? AsuswrtMapper.defaultPort(protocol) : undefined);
|
||||
const config: IAsuswrtConfig = {
|
||||
host,
|
||||
port,
|
||||
protocol,
|
||||
username: this.stringValue(valuesArg.username),
|
||||
password: this.stringValue(valuesArg.password),
|
||||
sshKey: this.stringValue(valuesArg.sshKey),
|
||||
mode: this.modeValue(valuesArg.mode) || this.modeValue(candidateArg.metadata?.mode) || snapshot?.router.mode || 'router',
|
||||
interface: this.stringValue(valuesArg.interface) || asuswrtDefaultInterface,
|
||||
dnsmasq: asuswrtDefaultDnsmasqPath,
|
||||
requireIp: true,
|
||||
trackUnknown: valuesArg.trackUnknown === true,
|
||||
considerHomeSeconds: asuswrtDefaultConsiderHomeSeconds,
|
||||
uniqueId: candidateArg.id || snapshot?.router.labelMac || snapshot?.router.macAddress,
|
||||
name: candidateArg.name || snapshot?.router.name,
|
||||
snapshot,
|
||||
metadata: {
|
||||
discoverySource: candidateArg.source,
|
||||
discoveryMetadata: candidateArg.metadata,
|
||||
liveSshTelnetImplemented: false,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'ASUSWRT configured',
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
private snapshotFromInput(valueArg: unknown): IAsuswrtSnapshot | undefined | Error {
|
||||
if (valueArg && typeof valueArg === 'object') {
|
||||
return valueArg as IAsuswrtSnapshot;
|
||||
}
|
||||
const text = this.stringValue(valueArg);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as IAsuswrtSnapshot;
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.router) {
|
||||
return new Error('Snapshot JSON must include a router object.');
|
||||
}
|
||||
return parsed;
|
||||
} catch (errorArg) {
|
||||
return errorArg instanceof Error ? errorArg : new Error(String(errorArg));
|
||||
}
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0) {
|
||||
return Math.round(valueArg);
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private protocolValue(valueArg: unknown): TAsuswrtProtocol | undefined {
|
||||
return valueArg === 'http' || valueArg === 'https' || valueArg === 'ssh' || valueArg === 'telnet' ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private modeValue(valueArg: unknown): TAsuswrtMode | undefined {
|
||||
return valueArg === 'router' || valueArg === 'ap' ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,95 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import type * as shxInterfaces from '@smarthome.exchange/interfaces';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { AsuswrtClient } from './asuswrt.classes.client.js';
|
||||
import { AsuswrtConfigFlow } from './asuswrt.classes.configflow.js';
|
||||
import { createAsuswrtDiscoveryDescriptor } from './asuswrt.discovery.js';
|
||||
import { AsuswrtMapper } from './asuswrt.mapper.js';
|
||||
import type { IAsuswrtConfig } from './asuswrt.types.js';
|
||||
import { asuswrtDomain } from './asuswrt.types.js';
|
||||
|
||||
export class HomeAssistantAsuswrtIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "asuswrt",
|
||||
displayName: "ASUSWRT",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/asuswrt",
|
||||
"upstreamDomain": "asuswrt",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"aioasuswrt==1.5.4",
|
||||
"asusrouter==1.21.3"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@kennedyshead",
|
||||
"@ollo69",
|
||||
"@Vaskivskyi"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class AsuswrtIntegration extends BaseIntegration<IAsuswrtConfig> {
|
||||
public readonly domain = asuswrtDomain;
|
||||
public readonly displayName = 'ASUSWRT';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createAsuswrtDiscoveryDescriptor();
|
||||
public readonly configFlow = new AsuswrtConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/asuswrt',
|
||||
upstreamDomain: asuswrtDomain,
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['aioasuswrt==1.5.4', 'asusrouter==1.21.3'],
|
||||
dependencies: [] as string[],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@kennedyshead', '@ollo69', '@Vaskivskyi'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/asuswrt',
|
||||
configFlow: true,
|
||||
runtime: {
|
||||
mode: 'native TypeScript snapshot/manual router mapping',
|
||||
platforms: ['sensor', 'binary_sensor', 'button'],
|
||||
services: ['refresh', 'snapshot', 'reboot', 'reconnect_device', 'disconnect_device', 'block_device', 'unblock_device'],
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'manual ASUSWRT router setup candidates and config flow',
|
||||
'snapshot mapping for router sensors, device tracker presence, interfaces, traffic counters/rates, CPU, memory, temperatures, uptime, and load average',
|
||||
'safe command modeling for explicitly declared router reboot and connected-device actions',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'homeassistant_compat shims',
|
||||
'fake SSH/Telnet connection or command success without commandExecutor/nativeClient injection',
|
||||
'full asusrouter/aioasuswrt live protocol implementation in dependency-free TypeScript',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IAsuswrtConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new AsuswrtRuntime(new AsuswrtClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantAsuswrtIntegration extends AsuswrtIntegration {}
|
||||
|
||||
class AsuswrtRuntime implements IIntegrationRuntime {
|
||||
public domain = asuswrtDomain;
|
||||
|
||||
constructor(private readonly client: AsuswrtClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return AsuswrtMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return AsuswrtMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(AsuswrtMapper.toIntegrationEvent(eventArg)));
|
||||
await this.client.getSnapshot();
|
||||
return async () => unsubscribe();
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.domain === asuswrtDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
|
||||
return { success: true, data: await this.client.getSnapshot() };
|
||||
}
|
||||
if (requestArg.domain === asuswrtDomain && requestArg.service === 'refresh') {
|
||||
return this.client.refresh();
|
||||
}
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const command = AsuswrtMapper.commandForService(snapshot, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported ASUSWRT service mapping: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
return this.client.sendCommand(command);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import { AsuswrtMapper } from './asuswrt.mapper.js';
|
||||
import type { IAsuswrtManualDiscoveryRecord, IAsuswrtSnapshot, TAsuswrtProtocol } from './asuswrt.types.js';
|
||||
import { asuswrtDomain } from './asuswrt.types.js';
|
||||
|
||||
const asusTextHints = ['asuswrt', 'asus router', 'asus wireless', 'rt-', 'gt-', 'zenwifi', 'aimesh', 'rog rapture'];
|
||||
|
||||
export class AsuswrtManualMatcher implements IDiscoveryMatcher<IAsuswrtManualDiscoveryRecord> {
|
||||
public id = 'asuswrt-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual ASUSWRT router setup entries, including snapshot-only records.';
|
||||
|
||||
public async matches(inputArg: IAsuswrtManualDiscoveryRecord): Promise<IDiscoveryMatch> {
|
||||
const metadata = inputArg.metadata || {};
|
||||
const snapshot = inputArg.snapshot || metadata.snapshot as IAsuswrtSnapshot | undefined;
|
||||
const host = inputArg.host || snapshot?.router.host;
|
||||
const protocol = this.protocol(inputArg.protocol || metadata.protocol || snapshot?.router.protocol) || 'https';
|
||||
const mac = AsuswrtMapper.normalizeMac(inputArg.macAddress || snapshot?.router.labelMac || snapshot?.router.macAddress);
|
||||
const text = [inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, metadata.manufacturer, metadata.model, metadata.name, snapshot?.router.manufacturer, snapshot?.router.model, snapshot?.router.name]
|
||||
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const hasSnapshot = Boolean(snapshot);
|
||||
const matched = inputArg.integrationDomain === asuswrtDomain
|
||||
|| metadata.asuswrt === true
|
||||
|| hasSnapshot
|
||||
|| asusTextHints.some((hintArg) => text.includes(hintArg))
|
||||
|| Boolean(host && !text);
|
||||
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain ASUSWRT setup hints.' };
|
||||
}
|
||||
|
||||
const port = inputArg.port || snapshot?.router.port || AsuswrtMapper.defaultPort(protocol);
|
||||
const id = inputArg.id || mac || snapshot?.router.id || (host ? `${host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: hasSnapshot || mac ? 'certain' : host ? 'high' : 'medium',
|
||||
reason: hasSnapshot ? 'Manual entry includes an ASUSWRT snapshot.' : 'Manual entry can start ASUSWRT router setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: asuswrtDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name: inputArg.name || snapshot?.router.name || host || 'ASUSWRT',
|
||||
manufacturer: inputArg.manufacturer || snapshot?.router.manufacturer || 'Asus',
|
||||
model: inputArg.model || snapshot?.router.model || 'Asus Router',
|
||||
macAddress: mac,
|
||||
metadata: {
|
||||
...metadata,
|
||||
asuswrt: true,
|
||||
protocol,
|
||||
mode: inputArg.mode || snapshot?.router.mode || 'router',
|
||||
hasSnapshot,
|
||||
liveSshTelnetImplemented: false,
|
||||
},
|
||||
},
|
||||
metadata: { hasSnapshot, protocol, liveSshTelnetImplemented: false },
|
||||
};
|
||||
}
|
||||
|
||||
private protocol(valueArg: unknown): TAsuswrtProtocol | undefined {
|
||||
return valueArg === 'http' || valueArg === 'https' || valueArg === 'ssh' || valueArg === 'telnet' ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class AsuswrtCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'asuswrt-candidate-validator';
|
||||
public description = 'Validate ASUSWRT manual candidates have a host or snapshot and router metadata.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = metadata.snapshot as IAsuswrtSnapshot | undefined;
|
||||
const mac = AsuswrtMapper.normalizeMac(candidateArg.macAddress || snapshot?.router.labelMac || snapshot?.router.macAddress);
|
||||
const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.manufacturer, metadata.model, metadata.name]
|
||||
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const matched = candidateArg.integrationDomain === asuswrtDomain
|
||||
|| metadata.asuswrt === true
|
||||
|| Boolean(snapshot)
|
||||
|| asusTextHints.some((hintArg) => text.includes(hintArg))
|
||||
|| candidateArg.source === 'manual' && Boolean(candidateArg.host);
|
||||
const hasUsableSource = Boolean(candidateArg.host || snapshot);
|
||||
|
||||
if (!matched || !hasUsableSource) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'ASUSWRT candidate lacks host or snapshot information.' : 'Candidate is not ASUSWRT.',
|
||||
};
|
||||
}
|
||||
|
||||
const protocol = this.protocol(metadata.protocol) || snapshot?.router.protocol || 'https';
|
||||
const port = candidateArg.port || snapshot?.router.port || AsuswrtMapper.defaultPort(protocol);
|
||||
const normalizedDeviceId = candidateArg.id || mac || (candidateArg.host ? `${candidateArg.host}:${port}` : snapshot?.router.id);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: mac || snapshot ? 'certain' : candidateArg.host ? 'high' : 'medium',
|
||||
reason: 'Candidate has ASUSWRT metadata and a usable manual source.',
|
||||
normalizedDeviceId,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
id: candidateArg.id || normalizedDeviceId,
|
||||
port,
|
||||
macAddress: mac || candidateArg.macAddress,
|
||||
},
|
||||
metadata: { protocol, liveSshTelnetImplemented: false },
|
||||
};
|
||||
}
|
||||
|
||||
private protocol(valueArg: unknown): TAsuswrtProtocol | undefined {
|
||||
return valueArg === 'http' || valueArg === 'https' || valueArg === 'ssh' || valueArg === 'telnet' ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const createAsuswrtDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: asuswrtDomain, displayName: 'ASUSWRT' })
|
||||
.addMatcher(new AsuswrtManualMatcher())
|
||||
.addValidator(new AsuswrtCandidateValidator());
|
||||
};
|
||||
@@ -0,0 +1,652 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||
import type {
|
||||
IAsuswrtActionDescriptor,
|
||||
IAsuswrtClientDevice,
|
||||
IAsuswrtCommand,
|
||||
IAsuswrtConfig,
|
||||
IAsuswrtEvent,
|
||||
IAsuswrtInterfaceStats,
|
||||
IAsuswrtManualEntry,
|
||||
IAsuswrtRouterInfo,
|
||||
IAsuswrtSensorMap,
|
||||
IAsuswrtSnapshot,
|
||||
TAsuswrtClientAction,
|
||||
TAsuswrtProtocol,
|
||||
TAsuswrtRouterAction,
|
||||
} from './asuswrt.types.js';
|
||||
import { asuswrtDefaultHttpPort, asuswrtDefaultHttpsPort, asuswrtDefaultSshPort, asuswrtDefaultTelnetPort, asuswrtDomain } from './asuswrt.types.js';
|
||||
|
||||
type TSensorDescriptor = {
|
||||
key: string;
|
||||
name: string;
|
||||
unit?: string;
|
||||
deviceClass?: string;
|
||||
stateClass?: string;
|
||||
entityCategory?: string;
|
||||
factor?: number;
|
||||
};
|
||||
|
||||
const manufacturer = 'Asus';
|
||||
const routerSensorDescriptors: TSensorDescriptor[] = [
|
||||
{ key: 'sensor_connected_device', name: 'Devices Connected', unit: 'devices', stateClass: 'measurement' },
|
||||
{ key: 'sensor_rx_rates', name: 'Download Speed', unit: 'Mbit/s', deviceClass: 'data_rate', stateClass: 'measurement', factor: 125000 },
|
||||
{ key: 'sensor_tx_rates', name: 'Upload Speed', unit: 'Mbit/s', deviceClass: 'data_rate', stateClass: 'measurement', factor: 125000 },
|
||||
{ key: 'sensor_rx_bytes', name: 'Download', unit: 'GB', deviceClass: 'data_size', stateClass: 'total_increasing', factor: 1000000000 },
|
||||
{ key: 'sensor_tx_bytes', name: 'Upload', unit: 'GB', deviceClass: 'data_size', stateClass: 'total_increasing', factor: 1000000000 },
|
||||
{ key: 'sensor_load_avg1', name: 'Average Load (1 min)', stateClass: 'measurement', entityCategory: 'diagnostic' },
|
||||
{ key: 'sensor_load_avg5', name: 'Average Load (5 min)', stateClass: 'measurement', entityCategory: 'diagnostic' },
|
||||
{ key: 'sensor_load_avg15', name: 'Average Load (15 min)', stateClass: 'measurement', entityCategory: 'diagnostic' },
|
||||
{ key: '2.4GHz', name: '2.4GHz Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' },
|
||||
{ key: '5.0GHz', name: '5GHz Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' },
|
||||
{ key: 'CPU', name: 'CPU Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' },
|
||||
{ key: '5.0GHz_2', name: '5GHz Temperature (Radio 2)', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' },
|
||||
{ key: '6.0GHz', name: '6GHz Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' },
|
||||
{ key: 'mem_usage_perc', name: 'Memory Usage', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
|
||||
{ key: 'mem_free', name: 'Memory Free', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', factor: 1024 },
|
||||
{ key: 'mem_used', name: 'Memory Used', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', factor: 1024 },
|
||||
{ key: 'sensor_last_boot', name: 'Last Boot', deviceClass: 'timestamp' },
|
||||
{ key: 'sensor_uptime', name: 'Uptime', unit: 's', deviceClass: 'duration', stateClass: 'total', entityCategory: 'diagnostic' },
|
||||
{ key: 'cpu_total_usage', name: 'CPU Usage', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' },
|
||||
...Array.from({ length: 8 }, (_valueArg, indexArg) => ({
|
||||
key: `cpu${indexArg + 1}_usage`,
|
||||
name: `CPU Core ${indexArg + 1} Usage`,
|
||||
unit: '%',
|
||||
stateClass: 'measurement',
|
||||
entityCategory: 'diagnostic',
|
||||
})),
|
||||
];
|
||||
|
||||
export class AsuswrtMapper {
|
||||
public static toSnapshot(configArg: IAsuswrtConfig, connectedArg?: boolean, eventsArg: IAsuswrtEvent[] = []): IAsuswrtSnapshot {
|
||||
const source = configArg.snapshot;
|
||||
const manualSnapshots = (configArg.manualEntries || [])
|
||||
.map((entryArg) => entryArg.snapshot)
|
||||
.filter((snapshotArg): snapshotArg is IAsuswrtSnapshot => Boolean(snapshotArg));
|
||||
const manualData = this.mergeManualEntries(configArg.manualEntries || []);
|
||||
const router = this.routerInfo(configArg, source, manualSnapshots, manualData.router);
|
||||
const devices = this.uniqueClients([
|
||||
...(source?.devices || []),
|
||||
...manualSnapshots.flatMap((snapshotArg) => snapshotArg.devices || []),
|
||||
...(configArg.devices || []),
|
||||
...(configArg.clients || []),
|
||||
...manualData.devices,
|
||||
]);
|
||||
const interfaces = this.uniqueInterfaces([
|
||||
...(source?.interfaces || []),
|
||||
...manualSnapshots.flatMap((snapshotArg) => snapshotArg.interfaces || []),
|
||||
...(configArg.interfaces || []),
|
||||
...manualData.interfaces,
|
||||
]);
|
||||
const sensors = this.sensorMap([
|
||||
source?.sensors,
|
||||
...manualSnapshots.map((snapshotArg) => snapshotArg.sensors),
|
||||
configArg.sensors,
|
||||
manualData.sensors,
|
||||
], devices.length, interfaces);
|
||||
const actions = this.uniqueActions([
|
||||
...(source?.actions || []),
|
||||
...manualSnapshots.flatMap((snapshotArg) => snapshotArg.actions || []),
|
||||
...(configArg.actions || []),
|
||||
...manualData.actions,
|
||||
...this.actionsFromRouter(router),
|
||||
...this.actionsFromClients(devices),
|
||||
]);
|
||||
const hasManualData = Boolean(source || manualSnapshots.length || configArg.router || configArg.devices?.length || configArg.clients?.length || configArg.interfaces?.length || configArg.sensors || manualData.hasData);
|
||||
|
||||
return {
|
||||
connected: connectedArg ?? configArg.connected ?? source?.connected ?? hasManualData,
|
||||
source: source?.source || (hasManualData ? 'manual' : 'runtime'),
|
||||
updatedAt: source?.updatedAt || new Date().toISOString(),
|
||||
router,
|
||||
devices,
|
||||
interfaces,
|
||||
sensors,
|
||||
actions,
|
||||
events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg],
|
||||
error: source?.error,
|
||||
metadata: {
|
||||
...source?.metadata,
|
||||
...configArg.metadata,
|
||||
liveSshTelnetImplemented: false,
|
||||
commandExecutorConfigured: Boolean(configArg.commandExecutor || configArg.nativeClient?.executeCommand),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public static toDevices(snapshotArg: IAsuswrtSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [this.routerDevice(snapshotArg, updatedAt)];
|
||||
for (const client of snapshotArg.devices) {
|
||||
devices.push(this.clientDevice(client, snapshotArg, updatedAt));
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IAsuswrtSnapshot): IIntegrationEntity[] {
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
const usedIds = new Map<string, number>();
|
||||
const routerDeviceId = this.routerDeviceId(snapshotArg);
|
||||
entities.push(this.entity('binary_sensor', `${this.routerName(snapshotArg)} Connected`, routerDeviceId, `${this.uniqueBase(snapshotArg)}_connected`, snapshotArg.connected ? 'on' : 'off', usedIds, {
|
||||
deviceClass: 'connectivity',
|
||||
host: snapshotArg.router.host,
|
||||
port: snapshotArg.router.port,
|
||||
protocol: snapshotArg.router.protocol,
|
||||
}, true));
|
||||
|
||||
for (const descriptor of routerSensorDescriptors) {
|
||||
const value = snapshotArg.sensors[descriptor.key];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
entities.push(this.entity('sensor', `${this.routerName(snapshotArg)} ${descriptor.name}`, routerDeviceId, `${this.uniqueBase(snapshotArg)}_${this.slug(descriptor.key)}`, this.sensorValue(value, descriptor), usedIds, {
|
||||
nativeKey: descriptor.key,
|
||||
unit: descriptor.unit,
|
||||
deviceClass: descriptor.deviceClass,
|
||||
stateClass: descriptor.stateClass,
|
||||
entityCategory: descriptor.entityCategory,
|
||||
}, snapshotArg.connected));
|
||||
}
|
||||
|
||||
for (const iface of snapshotArg.interfaces) {
|
||||
this.pushInterfaceEntities(entities, snapshotArg, iface, usedIds);
|
||||
}
|
||||
|
||||
for (const client of snapshotArg.devices) {
|
||||
this.pushClientEntities(entities, snapshotArg, client, usedIds);
|
||||
}
|
||||
|
||||
for (const action of this.snapshotActions(snapshotArg)) {
|
||||
const button = this.actionButton(snapshotArg, action, usedIds);
|
||||
if (button) {
|
||||
entities.push(button);
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static commandForService(snapshotArg: IAsuswrtSnapshot, requestArg: IServiceCallRequest): IAsuswrtCommand | undefined {
|
||||
const actions = this.snapshotActions(snapshotArg);
|
||||
const targetEntity = this.findTargetEntity(snapshotArg, requestArg);
|
||||
const serviceAction = this.actionFromService(requestArg.service);
|
||||
|
||||
if (requestArg.domain === asuswrtDomain && requestArg.service === 'reboot') {
|
||||
const action = actions.find((actionArg) => actionArg.target === 'router' && actionArg.action === 'reboot');
|
||||
return action ? this.command(snapshotArg, requestArg, action) : undefined;
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'button' && requestArg.service === 'press' && targetEntity?.attributes?.nativeAction) {
|
||||
const targetAction = String(targetEntity.attributes.nativeAction);
|
||||
const action = actions.find((actionArg) => actionArg.entityId === targetEntity.id || actionArg.action === targetAction && (actionArg.target === 'router' || actionArg.mac === targetEntity.attributes?.mac));
|
||||
return action ? this.command(snapshotArg, requestArg, action, targetEntity) : undefined;
|
||||
}
|
||||
|
||||
if (requestArg.domain !== asuswrtDomain || !serviceAction || serviceAction === 'reboot') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const mac = this.normalizeMac(this.stringValue(requestArg.data?.mac) || this.stringValue(requestArg.data?.macAddress) || this.stringValue(targetEntity?.attributes?.mac));
|
||||
const action = actions.find((actionArg) => actionArg.target === 'client' && actionArg.action === serviceAction && (!actionArg.mac || this.normalizeMac(actionArg.mac) === mac));
|
||||
return action && mac ? this.command(snapshotArg, requestArg, { ...action, mac }, targetEntity) : undefined;
|
||||
}
|
||||
|
||||
public static toIntegrationEvent(eventArg: IAsuswrtEvent): IIntegrationEvent {
|
||||
return {
|
||||
type: eventArg.type === 'command_failed' || eventArg.type === 'error' ? 'error' : 'state_changed',
|
||||
integrationDomain: asuswrtDomain,
|
||||
deviceId: eventArg.deviceId,
|
||||
entityId: eventArg.entityId,
|
||||
data: eventArg,
|
||||
timestamp: eventArg.timestamp || Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
public static normalizeMac(valueArg?: string): string | undefined {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
const compact = valueArg.replace(/[^0-9a-f]/gi, '').toLowerCase();
|
||||
return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase();
|
||||
}
|
||||
|
||||
public static defaultPort(protocolArg?: TAsuswrtProtocol): number {
|
||||
if (protocolArg === 'http') return asuswrtDefaultHttpPort;
|
||||
if (protocolArg === 'ssh') return asuswrtDefaultSshPort;
|
||||
if (protocolArg === 'telnet') return asuswrtDefaultTelnetPort;
|
||||
return asuswrtDefaultHttpsPort;
|
||||
}
|
||||
|
||||
public static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'asuswrt';
|
||||
}
|
||||
|
||||
private static routerInfo(configArg: IAsuswrtConfig, sourceArg: IAsuswrtSnapshot | undefined, manualSnapshotsArg: IAsuswrtSnapshot[], manualRouterArg?: IAsuswrtRouterInfo): IAsuswrtRouterInfo {
|
||||
const manualRouter = manualRouterArg || manualSnapshotsArg.find((snapshotArg) => snapshotArg.router)?.router;
|
||||
const router = {
|
||||
...sourceArg?.router,
|
||||
...manualRouter,
|
||||
...configArg.router,
|
||||
};
|
||||
const protocol = configArg.protocol || router.protocol || sourceArg?.router.protocol || 'https';
|
||||
const host = configArg.host || router.host || sourceArg?.router.host;
|
||||
const port = configArg.port || router.port || (host ? this.defaultPort(protocol) : undefined);
|
||||
const mac = this.normalizeMac(configArg.uniqueId || router.labelMac || router.macAddress || sourceArg?.router.labelMac || sourceArg?.router.macAddress);
|
||||
return {
|
||||
...router,
|
||||
id: router.id || mac || (host ? `${host}:${port || this.defaultPort(protocol)}` : undefined) || configArg.name || 'asuswrt',
|
||||
host,
|
||||
port,
|
||||
name: configArg.name || router.name || host || 'ASUSWRT',
|
||||
protocol,
|
||||
mode: configArg.mode || router.mode || 'router',
|
||||
labelMac: mac || router.labelMac,
|
||||
macAddress: mac || router.macAddress,
|
||||
manufacturer: router.manufacturer || manufacturer,
|
||||
configurationUrl: router.configurationUrl || (host ? `${protocol === 'https' ? 'https' : 'http'}://${host}${port && !this.isDefaultWebPort(protocol, port) ? `:${port}` : ''}` : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
private static mergeManualEntries(entriesArg: IAsuswrtManualEntry[]): { router?: IAsuswrtRouterInfo; devices: IAsuswrtClientDevice[]; interfaces: IAsuswrtInterfaceStats[]; sensors?: IAsuswrtSensorMap; actions: IAsuswrtActionDescriptor[]; hasData: boolean } {
|
||||
const devices: IAsuswrtClientDevice[] = [];
|
||||
const interfaces: IAsuswrtInterfaceStats[] = [];
|
||||
const actions: IAsuswrtActionDescriptor[] = [];
|
||||
const sensors: IAsuswrtSensorMap = {};
|
||||
let router: IAsuswrtRouterInfo | undefined;
|
||||
let hasData = false;
|
||||
for (const entry of entriesArg) {
|
||||
if (entry.router) {
|
||||
router = { ...router, ...entry.router };
|
||||
hasData = true;
|
||||
} else if (!router && (entry.host || entry.name || entry.model || entry.macAddress)) {
|
||||
router = {
|
||||
id: entry.id || entry.macAddress || entry.host,
|
||||
host: entry.host,
|
||||
port: entry.port,
|
||||
protocol: entry.protocol,
|
||||
mode: entry.mode,
|
||||
name: entry.name,
|
||||
model: entry.model,
|
||||
macAddress: entry.macAddress,
|
||||
manufacturer: entry.manufacturer,
|
||||
};
|
||||
hasData = true;
|
||||
}
|
||||
devices.push(...(entry.devices || []), ...(entry.clients || []));
|
||||
interfaces.push(...(entry.interfaces || []));
|
||||
Object.assign(sensors, entry.sensors || {});
|
||||
actions.push(...(entry.actions || []));
|
||||
hasData = hasData || Boolean(entry.devices?.length || entry.clients?.length || entry.interfaces?.length || entry.sensors || entry.actions?.length);
|
||||
}
|
||||
return { router, devices, interfaces, sensors: Object.keys(sensors).length ? sensors : undefined, actions, hasData };
|
||||
}
|
||||
|
||||
private static sensorMap(sourcesArg: Array<IAsuswrtSensorMap | undefined>, deviceCountArg: number, interfacesArg: IAsuswrtInterfaceStats[]): IAsuswrtSensorMap {
|
||||
const sensors: IAsuswrtSensorMap = {};
|
||||
for (const source of sourcesArg) {
|
||||
Object.assign(sensors, source || {});
|
||||
}
|
||||
if (sensors.sensor_connected_device === undefined && deviceCountArg > 0) {
|
||||
sensors.sensor_connected_device = deviceCountArg;
|
||||
}
|
||||
if (interfacesArg.length) {
|
||||
sensors.sensor_rx_bytes ??= this.sumInterfaces(interfacesArg, 'rxBytes', 'downloadBytes');
|
||||
sensors.sensor_tx_bytes ??= this.sumInterfaces(interfacesArg, 'txBytes', 'uploadBytes');
|
||||
sensors.sensor_rx_rates ??= this.sumInterfaces(interfacesArg, 'rxRate', 'downloadRate');
|
||||
sensors.sensor_tx_rates ??= this.sumInterfaces(interfacesArg, 'txRate', 'uploadRate');
|
||||
}
|
||||
return this.cleanAttributes(sensors) as IAsuswrtSensorMap;
|
||||
}
|
||||
|
||||
private static sumInterfaces(interfacesArg: IAsuswrtInterfaceStats[], primaryKeyArg: keyof IAsuswrtInterfaceStats, fallbackKeyArg: keyof IAsuswrtInterfaceStats): number | undefined {
|
||||
let total = 0;
|
||||
let found = false;
|
||||
for (const iface of interfacesArg) {
|
||||
const value = this.numberValue(iface[primaryKeyArg]) ?? this.numberValue(iface[fallbackKeyArg]);
|
||||
if (value !== undefined) {
|
||||
total += value;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
return found ? total : undefined;
|
||||
}
|
||||
|
||||
private static routerDevice(snapshotArg: IAsuswrtSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||
const sensors = snapshotArg.sensors;
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||
{ id: 'connected_devices', capability: 'sensor', name: 'Connected Devices', readable: true, writable: false },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt: updatedAtArg },
|
||||
{ featureId: 'connected_devices', value: sensors.sensor_connected_device ?? snapshotArg.devices.filter((deviceArg) => deviceArg.connected !== false).length, updatedAt: updatedAtArg },
|
||||
];
|
||||
this.addFeatureState(features, state, 'download_speed', 'Download Speed', this.sensorValue(sensors.sensor_rx_rates, routerSensorDescriptors[1]), updatedAtArg, 'Mbit/s');
|
||||
this.addFeatureState(features, state, 'upload_speed', 'Upload Speed', this.sensorValue(sensors.sensor_tx_rates, routerSensorDescriptors[2]), updatedAtArg, 'Mbit/s');
|
||||
this.addFeatureState(features, state, 'cpu_usage', 'CPU Usage', sensors.cpu_total_usage, updatedAtArg, '%');
|
||||
this.addFeatureState(features, state, 'memory_usage', 'Memory Usage', sensors.mem_usage_perc, updatedAtArg, '%');
|
||||
|
||||
return {
|
||||
id: this.routerDeviceId(snapshotArg),
|
||||
integrationDomain: asuswrtDomain,
|
||||
name: this.routerName(snapshotArg),
|
||||
protocol: this.deviceProtocol(snapshotArg.router.protocol),
|
||||
manufacturer: snapshotArg.router.manufacturer || manufacturer,
|
||||
model: snapshotArg.router.model || 'Asus Router',
|
||||
online: snapshotArg.connected,
|
||||
features,
|
||||
state,
|
||||
metadata: this.cleanAttributes({
|
||||
host: snapshotArg.router.host,
|
||||
port: snapshotArg.router.port,
|
||||
protocol: snapshotArg.router.protocol,
|
||||
mode: snapshotArg.router.mode,
|
||||
macAddress: snapshotArg.router.macAddress || snapshotArg.router.labelMac,
|
||||
modelId: snapshotArg.router.modelId,
|
||||
serialNumber: snapshotArg.router.serialNumber,
|
||||
firmware: snapshotArg.router.firmware,
|
||||
configurationUrl: snapshotArg.router.configurationUrl,
|
||||
source: snapshotArg.source,
|
||||
liveSshTelnetImplemented: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private static clientDevice(clientArg: IAsuswrtClientDevice, snapshotArg: IAsuswrtSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition {
|
||||
const name = this.clientName(clientArg);
|
||||
return {
|
||||
id: this.clientDeviceId(clientArg),
|
||||
integrationDomain: asuswrtDomain,
|
||||
name,
|
||||
protocol: 'unknown',
|
||||
manufacturer: clientArg.manufacturer || 'Unknown',
|
||||
model: clientArg.model || 'Network client',
|
||||
online: clientArg.connected !== false && snapshotArg.connected,
|
||||
features: [
|
||||
{ id: 'presence', capability: 'sensor', name: 'Presence', readable: true, writable: false },
|
||||
{ id: 'ip_address', capability: 'sensor', name: 'IP Address', readable: true, writable: false },
|
||||
],
|
||||
state: [
|
||||
{ featureId: 'presence', value: clientArg.connected !== false, updatedAt: updatedAtArg },
|
||||
{ featureId: 'ip_address', value: clientArg.ipAddress || clientArg.ip || null, updatedAt: updatedAtArg },
|
||||
],
|
||||
metadata: this.cleanAttributes({
|
||||
mac: this.clientMac(clientArg),
|
||||
ipAddress: clientArg.ipAddress || clientArg.ip,
|
||||
hostname: clientArg.hostname || clientArg.name,
|
||||
connectedTo: clientArg.connectedTo || clientArg.node || clientArg.interface,
|
||||
lastActivity: this.dateString(clientArg.lastActivity),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private static pushInterfaceEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IAsuswrtSnapshot, ifaceArg: IAsuswrtInterfaceStats, usedIdsArg: Map<string, number>): void {
|
||||
const deviceId = this.routerDeviceId(snapshotArg);
|
||||
const ifaceKey = this.slug(ifaceArg.id || ifaceArg.name);
|
||||
const ifaceName = ifaceArg.label || ifaceArg.name;
|
||||
const values: Array<[string, string, unknown, string | undefined, Record<string, unknown>]> = [
|
||||
['rx_bytes', 'Download', this.bytesToGigabytes(ifaceArg.rxBytes ?? ifaceArg.downloadBytes), 'GB', { deviceClass: 'data_size', stateClass: 'total_increasing' }],
|
||||
['tx_bytes', 'Upload', this.bytesToGigabytes(ifaceArg.txBytes ?? ifaceArg.uploadBytes), 'GB', { deviceClass: 'data_size', stateClass: 'total_increasing' }],
|
||||
['rx_rate', 'Download Speed', this.bytesPerSecondToMegabits(ifaceArg.rxRate ?? ifaceArg.downloadRate), 'Mbit/s', { deviceClass: 'data_rate', stateClass: 'measurement' }],
|
||||
['tx_rate', 'Upload Speed', this.bytesPerSecondToMegabits(ifaceArg.txRate ?? ifaceArg.uploadRate), 'Mbit/s', { deviceClass: 'data_rate', stateClass: 'measurement' }],
|
||||
];
|
||||
for (const [key, name, value, unit, attrs] of values) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
entitiesArg.push(this.entity('sensor', `${this.routerName(snapshotArg)} ${ifaceName} ${name}`, deviceId, `${this.uniqueBase(snapshotArg)}_interface_${ifaceKey}_${key}`, value, usedIdsArg, {
|
||||
...attrs,
|
||||
unit,
|
||||
interface: ifaceArg.name,
|
||||
}, snapshotArg.connected && ifaceArg.connected !== false));
|
||||
}
|
||||
if (ifaceArg.connected !== undefined) {
|
||||
entitiesArg.push(this.entity('binary_sensor', `${this.routerName(snapshotArg)} ${ifaceName} Link`, deviceId, `${this.uniqueBase(snapshotArg)}_interface_${ifaceKey}_link`, ifaceArg.connected ? 'on' : 'off', usedIdsArg, {
|
||||
deviceClass: 'connectivity',
|
||||
interface: ifaceArg.name,
|
||||
}, snapshotArg.connected));
|
||||
}
|
||||
}
|
||||
|
||||
private static pushClientEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IAsuswrtSnapshot, clientArg: IAsuswrtClientDevice, usedIdsArg: Map<string, number>): void {
|
||||
const mac = this.clientMac(clientArg);
|
||||
const deviceId = this.clientDeviceId(clientArg);
|
||||
entitiesArg.push(this.entity('binary_sensor', `${this.clientName(clientArg)} Connected`, deviceId, `${this.slug(mac || this.clientName(clientArg))}_connected`, clientArg.connected !== false ? 'on' : 'off', usedIdsArg, {
|
||||
deviceClass: 'connectivity',
|
||||
mac,
|
||||
ipAddress: clientArg.ipAddress || clientArg.ip,
|
||||
hostname: clientArg.hostname || clientArg.name,
|
||||
connectedTo: clientArg.connectedTo || clientArg.node || clientArg.interface,
|
||||
lastActivity: this.dateString(clientArg.lastActivity),
|
||||
}, snapshotArg.connected));
|
||||
}
|
||||
|
||||
private static actionButton(snapshotArg: IAsuswrtSnapshot, actionArg: IAsuswrtActionDescriptor, usedIdsArg: Map<string, number>): IIntegrationEntity | undefined {
|
||||
if (actionArg.target === 'router' && actionArg.action === 'reboot') {
|
||||
return this.entity('button', `${this.routerName(snapshotArg)} Reboot`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_reboot`, 'available', usedIdsArg, {
|
||||
nativeAction: 'reboot',
|
||||
actionTarget: 'router',
|
||||
writable: true,
|
||||
}, snapshotArg.connected, actionArg.entityId);
|
||||
}
|
||||
if (actionArg.target !== 'client' || !actionArg.mac) {
|
||||
return undefined;
|
||||
}
|
||||
const client = snapshotArg.devices.find((clientArg) => this.clientMac(clientArg) === this.normalizeMac(actionArg.mac));
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
return this.entity('button', `${this.clientName(client)} ${this.title(actionArg.action)}`, this.clientDeviceId(client), `${this.slug(actionArg.mac)}_${this.slug(actionArg.action)}`, 'available', usedIdsArg, {
|
||||
nativeAction: actionArg.action,
|
||||
actionTarget: 'client',
|
||||
mac: this.clientMac(client),
|
||||
writable: true,
|
||||
}, snapshotArg.connected && client.connected !== false, actionArg.entityId);
|
||||
}
|
||||
|
||||
private static command(snapshotArg: IAsuswrtSnapshot, requestArg: IServiceCallRequest, actionArg: IAsuswrtActionDescriptor, entityArg?: IIntegrationEntity): IAsuswrtCommand {
|
||||
return {
|
||||
type: actionArg.target === 'router' ? 'router.reboot' : 'client.action',
|
||||
service: requestArg.service,
|
||||
action: actionArg.action,
|
||||
target: requestArg.target,
|
||||
protocol: snapshotArg.router.protocol,
|
||||
routerId: this.routerDeviceId(snapshotArg),
|
||||
mac: actionArg.mac ? this.normalizeMac(actionArg.mac) : undefined,
|
||||
entityId: entityArg?.id || actionArg.entityId || requestArg.target.entityId,
|
||||
deviceId: entityArg?.deviceId || actionArg.deviceId || requestArg.target.deviceId,
|
||||
payload: { ...(requestArg.data || {}), actionMetadata: actionArg.metadata },
|
||||
};
|
||||
}
|
||||
|
||||
private static findTargetEntity(snapshotArg: IAsuswrtSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
|
||||
const targetEntityId = requestArg.target.entityId;
|
||||
if (!targetEntityId) {
|
||||
return undefined;
|
||||
}
|
||||
return this.toEntities(snapshotArg).find((entityArg) => entityArg.id === targetEntityId || entityArg.uniqueId === targetEntityId);
|
||||
}
|
||||
|
||||
private static actionFromService(serviceArg: string): TAsuswrtRouterAction | TAsuswrtClientAction | undefined {
|
||||
if (serviceArg === 'reboot') return 'reboot';
|
||||
if (serviceArg === 'reconnect_device' || serviceArg === 'reconnect_client') return 'reconnect';
|
||||
if (serviceArg === 'disconnect_device' || serviceArg === 'disconnect_client') return 'disconnect';
|
||||
if (serviceArg === 'block_device' || serviceArg === 'block_client') return 'block';
|
||||
if (serviceArg === 'unblock_device' || serviceArg === 'unblock_client') return 'unblock';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static actionsFromRouter(routerArg: IAsuswrtRouterInfo): IAsuswrtActionDescriptor[] {
|
||||
return (routerArg.actions || []).map((actionArg) => ({ target: 'router', action: actionArg }));
|
||||
}
|
||||
|
||||
private static actionsFromClients(devicesArg: IAsuswrtClientDevice[]): IAsuswrtActionDescriptor[] {
|
||||
const actions: IAsuswrtActionDescriptor[] = [];
|
||||
for (const device of devicesArg) {
|
||||
const mac = this.clientMac(device);
|
||||
if (!mac) {
|
||||
continue;
|
||||
}
|
||||
for (const action of device.actions || []) {
|
||||
actions.push({ target: 'client', action, mac });
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
private static snapshotActions(snapshotArg: IAsuswrtSnapshot): IAsuswrtActionDescriptor[] {
|
||||
return this.uniqueActions([
|
||||
...(snapshotArg.actions || []),
|
||||
...this.actionsFromRouter(snapshotArg.router),
|
||||
...this.actionsFromClients(snapshotArg.devices),
|
||||
]);
|
||||
}
|
||||
|
||||
private static uniqueClients(devicesArg: IAsuswrtClientDevice[]): IAsuswrtClientDevice[] {
|
||||
const seen = new Map<string, IAsuswrtClientDevice>();
|
||||
for (const device of devicesArg) {
|
||||
const key = this.clientMac(device) || device.id || device.ipAddress || device.ip || device.name;
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
seen.set(key, { ...seen.get(key), ...device, mac: this.clientMac(device) || device.mac });
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
private static uniqueInterfaces(interfacesArg: IAsuswrtInterfaceStats[]): IAsuswrtInterfaceStats[] {
|
||||
const seen = new Map<string, IAsuswrtInterfaceStats>();
|
||||
for (const iface of interfacesArg) {
|
||||
const key = iface.id || iface.name;
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
seen.set(key, { ...seen.get(key), ...iface });
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
private static uniqueActions(actionsArg: IAsuswrtActionDescriptor[]): IAsuswrtActionDescriptor[] {
|
||||
const seen = new Map<string, IAsuswrtActionDescriptor>();
|
||||
for (const action of actionsArg) {
|
||||
const mac = this.normalizeMac(action.mac);
|
||||
const key = [action.target, action.action, mac || action.entityId || action.deviceId || 'router'].join(':');
|
||||
seen.set(key, { ...action, mac });
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
private static routerDeviceId(snapshotArg: IAsuswrtSnapshot): string {
|
||||
return `asuswrt.router.${this.uniqueBase(snapshotArg)}`;
|
||||
}
|
||||
|
||||
private static clientDeviceId(clientArg: IAsuswrtClientDevice): string {
|
||||
return `asuswrt.client.${this.slug(this.clientMac(clientArg) || clientArg.id || clientArg.ipAddress || clientArg.ip || this.clientName(clientArg))}`;
|
||||
}
|
||||
|
||||
private static routerName(snapshotArg: IAsuswrtSnapshot): string {
|
||||
return snapshotArg.router.name || snapshotArg.router.host || 'ASUSWRT';
|
||||
}
|
||||
|
||||
private static clientName(clientArg: IAsuswrtClientDevice): string {
|
||||
return clientArg.name || clientArg.hostname || clientArg.macAddress || clientArg.mac || clientArg.ipAddress || clientArg.ip || 'Unknown device';
|
||||
}
|
||||
|
||||
private static clientMac(clientArg: IAsuswrtClientDevice): string | undefined {
|
||||
return this.normalizeMac(clientArg.macAddress || clientArg.mac);
|
||||
}
|
||||
|
||||
private static uniqueBase(snapshotArg: IAsuswrtSnapshot): string {
|
||||
return this.slug(snapshotArg.router.labelMac || snapshotArg.router.macAddress || snapshotArg.router.serialNumber || snapshotArg.router.id || snapshotArg.router.host || this.routerName(snapshotArg));
|
||||
}
|
||||
|
||||
private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map<string, number>, attributesArg: Record<string, unknown> = {}, availableArg = true, explicitIdArg?: string): IIntegrationEntity {
|
||||
const baseId = explicitIdArg || `${platformArg}.${this.slug(nameArg)}`;
|
||||
const used = usedIdsArg.get(baseId) || 0;
|
||||
usedIdsArg.set(baseId, used + 1);
|
||||
return {
|
||||
id: used ? `${baseId}_${used + 1}` : baseId,
|
||||
uniqueId: `${asuswrtDomain}_${this.slug(uniqueIdArg)}`,
|
||||
integrationDomain: asuswrtDomain,
|
||||
deviceId: deviceIdArg,
|
||||
platform: platformArg,
|
||||
name: nameArg,
|
||||
state: stateArg,
|
||||
attributes: this.cleanAttributes(attributesArg),
|
||||
available: availableArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static sensorValue(valueArg: unknown, descriptorArg: TSensorDescriptor | undefined): unknown {
|
||||
if (valueArg === undefined || valueArg === null) {
|
||||
return valueArg;
|
||||
}
|
||||
if (descriptorArg?.factor && typeof valueArg === 'number') {
|
||||
return valueArg / descriptorArg.factor;
|
||||
}
|
||||
return valueArg;
|
||||
}
|
||||
|
||||
private static addFeatureState(featuresArg: plugins.shxInterfaces.data.IDeviceFeature[], stateArg: plugins.shxInterfaces.data.IDeviceState[], idArg: string, nameArg: string, valueArg: unknown, updatedAtArg: string, unitArg?: string): void {
|
||||
if (valueArg === undefined) {
|
||||
return;
|
||||
}
|
||||
featuresArg.push({ id: idArg, capability: 'sensor', name: nameArg, readable: true, writable: false, unit: unitArg });
|
||||
stateArg.push({ featureId: idArg, value: this.deviceStateValue(valueArg), updatedAt: updatedAtArg });
|
||||
}
|
||||
|
||||
private static bytesToGigabytes(valueArg: number | undefined): number | undefined {
|
||||
return valueArg === undefined ? undefined : valueArg / 1000000000;
|
||||
}
|
||||
|
||||
private static bytesPerSecondToMegabits(valueArg: number | undefined): number | undefined {
|
||||
return valueArg === undefined ? undefined : valueArg / 125000;
|
||||
}
|
||||
|
||||
private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue {
|
||||
if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null) {
|
||||
return valueArg;
|
||||
}
|
||||
if (valueArg && typeof valueArg === 'object') {
|
||||
return valueArg as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static deviceProtocol(protocolArg?: TAsuswrtProtocol): plugins.shxInterfaces.data.TDeviceProtocol {
|
||||
if (protocolArg === 'http' || protocolArg === 'https') {
|
||||
return 'http';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
|
||||
}
|
||||
|
||||
private static stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private static numberValue(valueArg: unknown): number | undefined {
|
||||
return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private static dateString(valueArg: IAsuswrtClientDevice['lastActivity']): string | undefined {
|
||||
if (valueArg instanceof Date) {
|
||||
return valueArg.toISOString();
|
||||
}
|
||||
if (typeof valueArg === 'number') {
|
||||
return new Date(valueArg).toISOString();
|
||||
}
|
||||
return typeof valueArg === 'string' ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private static title(valueArg: string): string {
|
||||
return valueArg.replace(/_/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase());
|
||||
}
|
||||
|
||||
private static isDefaultWebPort(protocolArg: TAsuswrtProtocol, portArg: number): boolean {
|
||||
return (protocolArg === 'http' && portArg === asuswrtDefaultHttpPort) || (protocolArg === 'https' && portArg === 443);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,231 @@
|
||||
export interface IHomeAssistantAsuswrtConfig {
|
||||
// TODO: replace with the TypeScript-native config for asuswrt.
|
||||
import type { IServiceCallResult } from '../../core/types.js';
|
||||
|
||||
export const asuswrtDomain = 'asuswrt';
|
||||
export const asuswrtDefaultHttpPort = 80;
|
||||
export const asuswrtDefaultHttpsPort = 8443;
|
||||
export const asuswrtDefaultSshPort = 22;
|
||||
export const asuswrtDefaultTelnetPort = 23;
|
||||
export const asuswrtDefaultInterface = 'eth0';
|
||||
export const asuswrtDefaultDnsmasqPath = '/var/lib/misc';
|
||||
export const asuswrtDefaultConsiderHomeSeconds = 180;
|
||||
|
||||
export type TAsuswrtProtocol = 'http' | 'https' | 'ssh' | 'telnet';
|
||||
export type TAsuswrtMode = 'router' | 'ap';
|
||||
export type TAsuswrtSnapshotSource = 'snapshot' | 'manual' | 'provider' | 'runtime';
|
||||
export type TAsuswrtCommandType = 'router.reboot' | 'client.action';
|
||||
export type TAsuswrtRouterAction = 'reboot';
|
||||
export type TAsuswrtClientAction = 'reconnect' | 'disconnect' | 'block' | 'unblock';
|
||||
export type TAsuswrtAction = TAsuswrtRouterAction | TAsuswrtClientAction;
|
||||
|
||||
export interface IAsuswrtConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: TAsuswrtProtocol;
|
||||
username?: string;
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
mode?: TAsuswrtMode;
|
||||
interface?: string;
|
||||
dnsmasq?: string;
|
||||
requireIp?: boolean;
|
||||
trackUnknown?: boolean;
|
||||
considerHomeSeconds?: number;
|
||||
connected?: boolean;
|
||||
uniqueId?: string;
|
||||
name?: string;
|
||||
snapshot?: IAsuswrtSnapshot;
|
||||
router?: IAsuswrtRouterInfo;
|
||||
devices?: IAsuswrtClientDevice[];
|
||||
clients?: IAsuswrtClientDevice[];
|
||||
interfaces?: IAsuswrtInterfaceStats[];
|
||||
sensors?: IAsuswrtSensorMap;
|
||||
actions?: IAsuswrtActionDescriptor[];
|
||||
manualEntries?: IAsuswrtManualEntry[];
|
||||
events?: IAsuswrtEvent[];
|
||||
snapshotProvider?: TAsuswrtSnapshotProvider;
|
||||
commandExecutor?: TAsuswrtCommandExecutor;
|
||||
nativeClient?: IAsuswrtNativeClient;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantAsuswrtConfig extends IAsuswrtConfig {}
|
||||
|
||||
export interface IAsuswrtRouterInfo {
|
||||
id?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
model?: string;
|
||||
modelId?: string;
|
||||
serialNumber?: string;
|
||||
firmware?: string;
|
||||
macAddress?: string;
|
||||
labelMac?: string;
|
||||
configurationUrl?: string;
|
||||
mode?: TAsuswrtMode;
|
||||
protocol?: TAsuswrtProtocol;
|
||||
manufacturer?: string;
|
||||
actions?: TAsuswrtRouterAction[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAsuswrtClientDevice {
|
||||
id?: string;
|
||||
mac?: string;
|
||||
macAddress?: string;
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
ip?: string;
|
||||
ipAddress?: string;
|
||||
connected?: boolean;
|
||||
connectedTo?: string;
|
||||
node?: string;
|
||||
interface?: string;
|
||||
lastActivity?: string | number | Date;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
actions?: TAsuswrtClientAction[];
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IAsuswrtInterfaceStats {
|
||||
id?: string;
|
||||
name: string;
|
||||
label?: string;
|
||||
connected?: boolean;
|
||||
macAddress?: string;
|
||||
ipAddress?: string;
|
||||
rxBytes?: number;
|
||||
txBytes?: number;
|
||||
rxRate?: number;
|
||||
txRate?: number;
|
||||
downloadBytes?: number;
|
||||
uploadBytes?: number;
|
||||
downloadRate?: number;
|
||||
uploadRate?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAsuswrtSensorMap {
|
||||
sensor_connected_device?: number;
|
||||
sensor_rx_bytes?: number;
|
||||
sensor_tx_bytes?: number;
|
||||
sensor_rx_rates?: number;
|
||||
sensor_tx_rates?: number;
|
||||
sensor_load_avg1?: number;
|
||||
sensor_load_avg5?: number;
|
||||
sensor_load_avg15?: number;
|
||||
'2.4GHz'?: number;
|
||||
'5.0GHz'?: number;
|
||||
CPU?: number;
|
||||
'5.0GHz_2'?: number;
|
||||
'6.0GHz'?: number;
|
||||
mem_usage_perc?: number;
|
||||
mem_free?: number;
|
||||
mem_used?: number;
|
||||
sensor_last_boot?: string | number;
|
||||
sensor_uptime?: number;
|
||||
cpu_total_usage?: number;
|
||||
cpu1_usage?: number;
|
||||
cpu2_usage?: number;
|
||||
cpu3_usage?: number;
|
||||
cpu4_usage?: number;
|
||||
cpu5_usage?: number;
|
||||
cpu6_usage?: number;
|
||||
cpu7_usage?: number;
|
||||
cpu8_usage?: number;
|
||||
[key: string]: string | number | boolean | null | undefined;
|
||||
}
|
||||
|
||||
export interface IAsuswrtActionDescriptor {
|
||||
target: 'router' | 'client';
|
||||
action: TAsuswrtAction;
|
||||
service?: string;
|
||||
mac?: string;
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
label?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAsuswrtSnapshot {
|
||||
connected: boolean;
|
||||
source?: TAsuswrtSnapshotSource;
|
||||
updatedAt?: string;
|
||||
router: IAsuswrtRouterInfo;
|
||||
devices: IAsuswrtClientDevice[];
|
||||
interfaces: IAsuswrtInterfaceStats[];
|
||||
sensors: IAsuswrtSensorMap;
|
||||
actions?: IAsuswrtActionDescriptor[];
|
||||
events?: IAsuswrtEvent[];
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAsuswrtManualEntry {
|
||||
id?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: TAsuswrtProtocol;
|
||||
mode?: TAsuswrtMode;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
macAddress?: string;
|
||||
router?: IAsuswrtRouterInfo;
|
||||
devices?: IAsuswrtClientDevice[];
|
||||
clients?: IAsuswrtClientDevice[];
|
||||
interfaces?: IAsuswrtInterfaceStats[];
|
||||
sensors?: IAsuswrtSensorMap;
|
||||
actions?: IAsuswrtActionDescriptor[];
|
||||
snapshot?: IAsuswrtSnapshot;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IAsuswrtManualDiscoveryRecord extends IAsuswrtManualEntry {
|
||||
integrationDomain?: string;
|
||||
}
|
||||
|
||||
export interface IAsuswrtCommand {
|
||||
type: TAsuswrtCommandType;
|
||||
service: string;
|
||||
action: TAsuswrtAction;
|
||||
target: {
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
protocol?: TAsuswrtProtocol;
|
||||
routerId?: string;
|
||||
mac?: string;
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAsuswrtCommandResult extends IServiceCallResult {}
|
||||
|
||||
export interface IAsuswrtEvent {
|
||||
type: string;
|
||||
timestamp?: number;
|
||||
deviceId?: string;
|
||||
entityId?: string;
|
||||
command?: IAsuswrtCommand;
|
||||
snapshot?: IAsuswrtSnapshot;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IAsuswrtNativeClient {
|
||||
getSnapshot(): Promise<IAsuswrtSnapshot> | IAsuswrtSnapshot;
|
||||
executeCommand?(commandArg: IAsuswrtCommand): Promise<IAsuswrtCommandResult | unknown> | IAsuswrtCommandResult | unknown;
|
||||
destroy?(): Promise<void> | void;
|
||||
}
|
||||
|
||||
export type TAsuswrtSnapshotProvider = () => Promise<IAsuswrtSnapshot | undefined> | IAsuswrtSnapshot | undefined;
|
||||
export type TAsuswrtCommandExecutor = (
|
||||
commandArg: IAsuswrtCommand
|
||||
) => Promise<IAsuswrtCommandResult | unknown> | IAsuswrtCommandResult | unknown;
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './asuswrt.classes.client.js';
|
||||
export * from './asuswrt.classes.configflow.js';
|
||||
export * from './asuswrt.classes.integration.js';
|
||||
export * from './asuswrt.discovery.js';
|
||||
export * from './asuswrt.mapper.js';
|
||||
export * from './asuswrt.types.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
|
||||
@@ -0,0 +1,130 @@
|
||||
import { BluetoothLeTrackerMapper } from './bluetooth_le_tracker.mapper.js';
|
||||
import type {
|
||||
IBluetoothLeAdvertisement,
|
||||
IBluetoothLeTrackedDevice,
|
||||
IBluetoothLeTrackerCommand,
|
||||
IBluetoothLeTrackerCommandResult,
|
||||
IBluetoothLeTrackerConfig,
|
||||
IBluetoothLeTrackerEvent,
|
||||
IBluetoothLeTrackerSnapshot,
|
||||
} from './bluetooth_le_tracker.types.js';
|
||||
|
||||
type TBluetoothLeTrackerEventHandler = (eventArg: IBluetoothLeTrackerEvent) => void;
|
||||
|
||||
export class BluetoothLeTrackerClient {
|
||||
private injectedSnapshot?: IBluetoothLeTrackerSnapshot;
|
||||
private readonly injectedDevices: IBluetoothLeTrackedDevice[] = [];
|
||||
private readonly injectedAdvertisements: IBluetoothLeAdvertisement[] = [];
|
||||
private readonly events: IBluetoothLeTrackerEvent[] = [];
|
||||
private readonly eventHandlers = new Set<TBluetoothLeTrackerEventHandler>();
|
||||
|
||||
constructor(private readonly config: IBluetoothLeTrackerConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<IBluetoothLeTrackerSnapshot> {
|
||||
return BluetoothLeTrackerMapper.toSnapshot(this.runtimeConfig(), undefined, this.events);
|
||||
}
|
||||
|
||||
public onEvent(handlerArg: TBluetoothLeTrackerEventHandler): () => void {
|
||||
this.eventHandlers.add(handlerArg);
|
||||
return () => this.eventHandlers.delete(handlerArg);
|
||||
}
|
||||
|
||||
public async sendCommand(commandArg: IBluetoothLeTrackerCommand): Promise<IBluetoothLeTrackerCommandResult> {
|
||||
if (commandArg.type !== 'scan') {
|
||||
return { success: false, error: `Unsupported Bluetooth LE Tracker command: ${commandArg.type}` };
|
||||
}
|
||||
const payload = await this.injectedPayload(commandArg);
|
||||
if (!payload) {
|
||||
const error = 'Bluetooth LE live scanning is not implemented in this dependency-free TypeScript port. Provide injected advertisements, devices, a snapshot, or scanProvider data to refresh tracker state.';
|
||||
this.emit({ type: 'unsupported_scan', data: { service: commandArg.service }, timestamp: Date.now() });
|
||||
return { success: false, error };
|
||||
}
|
||||
if (payload.snapshot) {
|
||||
this.injectedSnapshot = payload.snapshot;
|
||||
}
|
||||
if (payload.devices?.length) {
|
||||
this.injectedDevices.push(...payload.devices);
|
||||
}
|
||||
if (payload.advertisements?.length) {
|
||||
this.injectedAdvertisements.push(...payload.advertisements);
|
||||
}
|
||||
const snapshot = await this.getSnapshot();
|
||||
this.emit({ type: payload.snapshot ? 'snapshot_updated' : 'scan_applied', data: { service: commandArg.service, snapshot }, timestamp: Date.now() });
|
||||
return { success: true, data: { snapshot } };
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.eventHandlers.clear();
|
||||
}
|
||||
|
||||
private runtimeConfig(): IBluetoothLeTrackerConfig {
|
||||
return {
|
||||
...this.config,
|
||||
snapshot: this.injectedSnapshot || this.config.snapshot,
|
||||
devices: [...(this.config.devices || []), ...this.injectedDevices],
|
||||
advertisements: [...(this.config.advertisements || []), ...this.injectedAdvertisements],
|
||||
};
|
||||
}
|
||||
|
||||
private async injectedPayload(commandArg: IBluetoothLeTrackerCommand): Promise<{
|
||||
snapshot?: IBluetoothLeTrackerSnapshot;
|
||||
devices?: IBluetoothLeTrackedDevice[];
|
||||
advertisements?: IBluetoothLeAdvertisement[];
|
||||
} | undefined> {
|
||||
if (commandArg.snapshot || commandArg.devices?.length || commandArg.advertisements?.length) {
|
||||
return {
|
||||
snapshot: commandArg.snapshot,
|
||||
devices: commandArg.devices,
|
||||
advertisements: commandArg.advertisements,
|
||||
};
|
||||
}
|
||||
if (this.config.scanProvider) {
|
||||
const result = await this.config.scanProvider();
|
||||
return this.providerResult(result);
|
||||
}
|
||||
if (this.hasInjectedConfigData()) {
|
||||
return {
|
||||
snapshot: this.config.snapshot,
|
||||
devices: [...(this.config.devices || []), ...(this.config.knownDevices || []), ...(this.config.trackedDevices || [])],
|
||||
advertisements: this.config.advertisements,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private providerResult(valueArg: unknown): {
|
||||
snapshot?: IBluetoothLeTrackerSnapshot;
|
||||
devices?: IBluetoothLeTrackedDevice[];
|
||||
advertisements?: IBluetoothLeAdvertisement[];
|
||||
} | undefined {
|
||||
if (Array.isArray(valueArg)) {
|
||||
return { advertisements: valueArg as IBluetoothLeAdvertisement[] };
|
||||
}
|
||||
if (!this.isRecord(valueArg)) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(valueArg.devices) || Array.isArray(valueArg.advertisements) || this.isRecord(valueArg.snapshot)) {
|
||||
return {
|
||||
snapshot: this.isRecord(valueArg.snapshot) ? valueArg.snapshot as unknown as IBluetoothLeTrackerSnapshot : undefined,
|
||||
devices: Array.isArray(valueArg.devices) ? valueArg.devices as IBluetoothLeTrackedDevice[] : undefined,
|
||||
advertisements: Array.isArray(valueArg.advertisements) ? valueArg.advertisements as IBluetoothLeAdvertisement[] : undefined,
|
||||
};
|
||||
}
|
||||
return { snapshot: valueArg as unknown as IBluetoothLeTrackerSnapshot };
|
||||
}
|
||||
|
||||
private hasInjectedConfigData(): boolean {
|
||||
return Boolean(this.config.snapshot || this.config.devices?.length || this.config.knownDevices?.length || this.config.trackedDevices?.length || this.config.advertisements?.length || this.config.manualEntries?.length);
|
||||
}
|
||||
|
||||
private emit(eventArg: IBluetoothLeTrackerEvent): void {
|
||||
this.events.push(eventArg);
|
||||
for (const handler of this.eventHandlers) {
|
||||
handler(eventArg);
|
||||
}
|
||||
}
|
||||
|
||||
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import { BluetoothLeTrackerMapper } from './bluetooth_le_tracker.mapper.js';
|
||||
import type { IBluetoothLeAdvertisement, IBluetoothLeTrackedDevice, IBluetoothLeTrackerConfig, IBluetoothLeTrackerSnapshot } from './bluetooth_le_tracker.types.js';
|
||||
import {
|
||||
bluetoothLeTrackerDefaultConsiderHomeSeconds,
|
||||
bluetoothLeTrackerDefaultScanIntervalSeconds,
|
||||
bluetoothLeTrackerDefaultTrackBatteryIntervalSeconds,
|
||||
} from './bluetooth_le_tracker.types.js';
|
||||
|
||||
export class BluetoothLeTrackerConfigFlow implements IConfigFlow<IBluetoothLeTrackerConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IBluetoothLeTrackerConfig>> {
|
||||
void contextArg;
|
||||
const defaults = this.defaultsFromCandidate(candidateArg);
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Configure Bluetooth LE Tracker',
|
||||
description: 'Configure known BLE devices or provide advertisement/snapshot data. Live Bluetooth scanning is not performed by this dependency-free TypeScript port.',
|
||||
fields: [
|
||||
{ name: 'address', label: 'BLE address', type: 'text' },
|
||||
{ name: 'name', label: 'Device name', type: 'text' },
|
||||
{ name: 'trackNewDevices', label: 'Track new devices after repeated advertisements', type: 'boolean' },
|
||||
{ name: 'trackBattery', label: 'Track battery from injected data', type: 'boolean' },
|
||||
{ name: 'trackBatteryIntervalSeconds', label: 'Battery refresh interval seconds', type: 'number' },
|
||||
{ name: 'scanIntervalSeconds', label: 'Scanner refresh interval seconds', type: 'number' },
|
||||
{ name: 'considerHomeSeconds', label: 'Consider home seconds', type: 'number' },
|
||||
{ name: 'knownDevicesJson', label: 'Known devices JSON', type: 'text' },
|
||||
{ name: 'advertisementsJson', label: 'Advertisements JSON', type: 'text' },
|
||||
{ name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const knownDevices = this.arrayValue<IBluetoothLeTrackedDevice>(valuesArg.knownDevicesJson, defaults.knownDevices);
|
||||
if (knownDevices === false) {
|
||||
return { kind: 'error', title: 'Invalid known devices', error: 'Known devices JSON must be an array.' };
|
||||
}
|
||||
const advertisements = this.arrayValue<IBluetoothLeAdvertisement>(valuesArg.advertisementsJson, defaults.advertisements);
|
||||
if (advertisements === false) {
|
||||
return { kind: 'error', title: 'Invalid advertisements', error: 'Advertisements JSON must be an array.' };
|
||||
}
|
||||
const snapshot = this.snapshotValue(valuesArg.snapshotJson, defaults.snapshot);
|
||||
if (snapshot === false) {
|
||||
return { kind: 'error', title: 'Invalid BLE tracker snapshot', error: 'Snapshot JSON must be a JSON object.' };
|
||||
}
|
||||
const address = BluetoothLeTrackerMapper.normalizeAddress(valuesArg.address) || defaults.address;
|
||||
const name = this.stringValue(valuesArg.name) || defaults.name;
|
||||
const trackedDevices = [...(knownDevices || [])];
|
||||
if (address && !trackedDevices.some((deviceArg) => BluetoothLeTrackerMapper.normalizeAddress(deviceArg.address || deviceArg.mac || deviceArg.macAddress || deviceArg.haMac) === address)) {
|
||||
trackedDevices.push({ address, name, track: true, trackBattery: this.booleanValue(valuesArg.trackBattery) ?? defaults.trackBattery });
|
||||
}
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Bluetooth LE Tracker configured',
|
||||
config: {
|
||||
scanIntervalSeconds: this.numberValue(valuesArg.scanIntervalSeconds) ?? defaults.scanIntervalSeconds ?? bluetoothLeTrackerDefaultScanIntervalSeconds,
|
||||
trackNewDevices: this.booleanValue(valuesArg.trackNewDevices) ?? defaults.trackNewDevices ?? true,
|
||||
trackBattery: this.booleanValue(valuesArg.trackBattery) ?? defaults.trackBattery ?? false,
|
||||
trackBatteryIntervalSeconds: this.numberValue(valuesArg.trackBatteryIntervalSeconds) ?? defaults.trackBatteryIntervalSeconds ?? bluetoothLeTrackerDefaultTrackBatteryIntervalSeconds,
|
||||
considerHomeSeconds: this.numberValue(valuesArg.considerHomeSeconds) ?? defaults.considerHomeSeconds ?? bluetoothLeTrackerDefaultConsiderHomeSeconds,
|
||||
knownDevices: trackedDevices,
|
||||
advertisements: advertisements || undefined,
|
||||
snapshot: snapshot || undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private defaultsFromCandidate(candidateArg: IDiscoveryCandidate): {
|
||||
address?: string;
|
||||
name?: string;
|
||||
trackNewDevices?: boolean;
|
||||
trackBattery?: boolean;
|
||||
trackBatteryIntervalSeconds?: number;
|
||||
scanIntervalSeconds?: number;
|
||||
considerHomeSeconds?: number;
|
||||
knownDevices?: IBluetoothLeTrackedDevice[];
|
||||
advertisements?: IBluetoothLeAdvertisement[];
|
||||
snapshot?: IBluetoothLeTrackerSnapshot;
|
||||
} {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
return {
|
||||
address: BluetoothLeTrackerMapper.normalizeAddress(candidateArg.macAddress || metadata.address || metadata.haMac),
|
||||
name: candidateArg.name,
|
||||
trackNewDevices: this.booleanValue(metadata.trackNewDevices),
|
||||
trackBattery: this.booleanValue(metadata.trackBattery),
|
||||
trackBatteryIntervalSeconds: this.numberValue(metadata.trackBatteryIntervalSeconds),
|
||||
scanIntervalSeconds: this.numberValue(metadata.scanIntervalSeconds),
|
||||
considerHomeSeconds: this.numberValue(metadata.considerHomeSeconds),
|
||||
knownDevices: this.arrayCandidateValue<IBluetoothLeTrackedDevice>(metadata.knownDevices) || this.arrayCandidateValue<IBluetoothLeTrackedDevice>(metadata.trackedDevices) || this.arrayCandidateValue<IBluetoothLeTrackedDevice>(metadata.devices),
|
||||
advertisements: this.arrayCandidateValue<IBluetoothLeAdvertisement>(metadata.advertisements) || this.arrayCandidateValue<IBluetoothLeAdvertisement>(metadata.advertisement ? [metadata.advertisement] : undefined),
|
||||
snapshot: this.isSnapshot(metadata.snapshot) ? metadata.snapshot : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private arrayValue<TValue>(valueArg: unknown, fallbackArg?: TValue[]): TValue[] | undefined | false {
|
||||
if (valueArg === undefined || valueArg === null || valueArg === '') {
|
||||
return fallbackArg;
|
||||
}
|
||||
if (Array.isArray(valueArg)) {
|
||||
return valueArg as TValue[];
|
||||
}
|
||||
if (typeof valueArg !== 'string') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(valueArg) as unknown;
|
||||
return Array.isArray(parsed) ? parsed as TValue[] : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private snapshotValue(valueArg: unknown, fallbackArg?: IBluetoothLeTrackerSnapshot): IBluetoothLeTrackerSnapshot | undefined | false {
|
||||
if (valueArg === undefined || valueArg === null || valueArg === '') {
|
||||
return fallbackArg;
|
||||
}
|
||||
if (this.isSnapshot(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg !== 'string') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(valueArg) as unknown;
|
||||
return this.isRecord(parsed) ? parsed as unknown as IBluetoothLeTrackerSnapshot : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private arrayCandidateValue<TValue>(valueArg: unknown): TValue[] | undefined {
|
||||
return Array.isArray(valueArg) ? valueArg as TValue[] : undefined;
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
}
|
||||
|
||||
private booleanValue(valueArg: unknown): boolean | undefined {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
if (['true', '1', 'yes', 'on'].includes(valueArg.toLowerCase())) return true;
|
||||
if (['false', '0', 'no', 'off'].includes(valueArg.toLowerCase())) return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isSnapshot(valueArg: unknown): valueArg is IBluetoothLeTrackerSnapshot {
|
||||
return this.isRecord(valueArg);
|
||||
}
|
||||
|
||||
private isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
|
||||
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,107 @@
|
||||
import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
||||
import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { BluetoothLeTrackerClient } from './bluetooth_le_tracker.classes.client.js';
|
||||
import { BluetoothLeTrackerConfigFlow } from './bluetooth_le_tracker.classes.configflow.js';
|
||||
import { createBluetoothLeTrackerDiscoveryDescriptor } from './bluetooth_le_tracker.discovery.js';
|
||||
import { BluetoothLeTrackerMapper } from './bluetooth_le_tracker.mapper.js';
|
||||
import type { IBluetoothLeAdvertisement, IBluetoothLeTrackedDevice, IBluetoothLeTrackerCommand, IBluetoothLeTrackerConfig, IBluetoothLeTrackerSnapshot } from './bluetooth_le_tracker.types.js';
|
||||
import { bluetoothLeTrackerDomain } from './bluetooth_le_tracker.types.js';
|
||||
|
||||
export class HomeAssistantBluetoothLeTrackerIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "bluetooth_le_tracker",
|
||||
displayName: "Bluetooth LE Tracker",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/bluetooth_le_tracker",
|
||||
"upstreamDomain": "bluetooth_le_tracker",
|
||||
"iotClass": "local_push",
|
||||
"qualityScale": "legacy",
|
||||
"requirements": [],
|
||||
"dependencies": [
|
||||
"bluetooth_adapters"
|
||||
],
|
||||
"afterDependencies": [],
|
||||
"codeowners": []
|
||||
},
|
||||
});
|
||||
export class BluetoothLeTrackerIntegration extends BaseIntegration<IBluetoothLeTrackerConfig> {
|
||||
public readonly domain = bluetoothLeTrackerDomain;
|
||||
public readonly displayName = 'Bluetooth LE Tracker';
|
||||
public readonly status = 'read-only-runtime' as const;
|
||||
public readonly discoveryDescriptor = createBluetoothLeTrackerDiscoveryDescriptor();
|
||||
public readonly configFlow = new BluetoothLeTrackerConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/bluetooth_le_tracker',
|
||||
upstreamDomain: bluetoothLeTrackerDomain,
|
||||
iotClass: 'local_push',
|
||||
qualityScale: 'legacy',
|
||||
requirements: [] as string[],
|
||||
dependencies: ['bluetooth_adapters'],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: [] as string[],
|
||||
documentation: 'https://www.home-assistant.io/integrations/bluetooth_le_tracker',
|
||||
nativeBehavior: 'Maps injected BLE advertisement/snapshot data into device-tracker-like binary_sensor/sensor entities. Live Bluetooth adapter scanning is intentionally not implemented.',
|
||||
};
|
||||
|
||||
public async setup(configArg: IBluetoothLeTrackerConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new BluetoothLeTrackerRuntime(new BluetoothLeTrackerClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantBluetoothLeTrackerIntegration extends BluetoothLeTrackerIntegration {}
|
||||
|
||||
class BluetoothLeTrackerRuntime implements IIntegrationRuntime {
|
||||
public domain = bluetoothLeTrackerDomain;
|
||||
|
||||
constructor(private readonly client: BluetoothLeTrackerClient) {}
|
||||
|
||||
public async devices(): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return BluetoothLeTrackerMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return BluetoothLeTrackerMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||
const unsubscribe = this.client.onEvent((eventArg) => handlerArg({
|
||||
type: eventArg.type === 'unsupported_scan' ? 'error' : 'state_changed',
|
||||
integrationDomain: bluetoothLeTrackerDomain,
|
||||
deviceId: eventArg.deviceId,
|
||||
entityId: eventArg.entityId,
|
||||
data: eventArg,
|
||||
timestamp: eventArg.timestamp || Date.now(),
|
||||
}));
|
||||
await this.client.getSnapshot();
|
||||
return async () => unsubscribe();
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
const command = this.commandFromService(requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported Bluetooth LE Tracker service: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
const result = await this.client.sendCommand(command);
|
||||
return { success: result.success, error: result.error, data: result.data };
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
|
||||
private commandFromService(requestArg: IServiceCallRequest): IBluetoothLeTrackerCommand | undefined {
|
||||
if (requestArg.domain !== bluetoothLeTrackerDomain || !['scan', 'scan_once', 'refresh', 'refresh_devices'].includes(requestArg.service)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
type: 'scan',
|
||||
service: requestArg.service,
|
||||
target: requestArg.target,
|
||||
snapshot: this.snapshotValue(requestArg.data?.snapshot),
|
||||
devices: this.arrayValue<IBluetoothLeTrackedDevice>(requestArg.data?.devices || requestArg.data?.trackedDevices || requestArg.data?.knownDevices),
|
||||
advertisements: this.arrayValue<IBluetoothLeAdvertisement>(requestArg.data?.advertisements || requestArg.data?.advertisement),
|
||||
};
|
||||
}
|
||||
|
||||
private arrayValue<TValue>(valueArg: unknown): TValue[] | undefined {
|
||||
if (Array.isArray(valueArg)) {
|
||||
return valueArg as TValue[];
|
||||
}
|
||||
if (valueArg && typeof valueArg === 'object') {
|
||||
return [valueArg as TValue];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private snapshotValue(valueArg: unknown): IBluetoothLeTrackerSnapshot | undefined {
|
||||
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IBluetoothLeTrackerSnapshot : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import { BluetoothLeTrackerMapper } from './bluetooth_le_tracker.mapper.js';
|
||||
import type { IBluetoothLeDiscoveryRecord, IBluetoothLeManualEntry } from './bluetooth_le_tracker.types.js';
|
||||
import { bluetoothLeTrackerDomain, bluetoothLeTrackerSourceType } from './bluetooth_le_tracker.types.js';
|
||||
|
||||
const bleTextHints = ['bluetooth', 'ble', 'beacon', 'ibeacon', 'tag', 'tracker'];
|
||||
|
||||
export class BluetoothLeAdvertisementMatcher implements IDiscoveryMatcher<IBluetoothLeDiscoveryRecord> {
|
||||
public id = 'bluetooth-le-tracker-bluetooth-match';
|
||||
public source = 'bluetooth' as const;
|
||||
public description = 'Recognize Bluetooth LE advertisements that can feed the BLE tracker.';
|
||||
|
||||
public async matches(recordArg: IBluetoothLeDiscoveryRecord): Promise<IDiscoveryMatch> {
|
||||
const address = BluetoothLeTrackerMapper.normalizeAddress(recordArg.address || recordArg.mac || recordArg.macAddress || recordArg.haMac || recordArg.id);
|
||||
if (!address) {
|
||||
return { matched: false, confidence: 'low', reason: 'Bluetooth record does not include a trackable BLE MAC address.' };
|
||||
}
|
||||
const name = BluetoothLeTrackerMapper.cleanName(recordArg.name || recordArg.localName || recordArg.hostName);
|
||||
const hasBlePayload = Boolean(recordArg.serviceUuids?.length || recordArg.serviceUUIDs?.length || recordArg.serviceData || recordArg.manufacturerData || typeof recordArg.rssi === 'number' || recordArg.connectable !== undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: recordArg.sourceType === bluetoothLeTrackerSourceType ? 'certain' : hasBlePayload ? 'high' : 'medium',
|
||||
reason: hasBlePayload ? 'Bluetooth record contains BLE advertisement metadata.' : 'Bluetooth record contains a BLE address.',
|
||||
normalizedDeviceId: address,
|
||||
candidate: {
|
||||
source: 'bluetooth',
|
||||
integrationDomain: bluetoothLeTrackerDomain,
|
||||
id: address,
|
||||
name: name || BluetoothLeTrackerMapper.haMac(address),
|
||||
manufacturer: 'Bluetooth',
|
||||
model: 'Bluetooth LE device',
|
||||
macAddress: address,
|
||||
metadata: {
|
||||
address,
|
||||
haMac: BluetoothLeTrackerMapper.haMac(address),
|
||||
sourceType: bluetoothLeTrackerSourceType,
|
||||
rssi: recordArg.rssi,
|
||||
connectable: recordArg.connectable,
|
||||
serviceUuids: recordArg.serviceUuids || recordArg.serviceUUIDs,
|
||||
serviceData: recordArg.serviceData,
|
||||
manufacturerData: recordArg.manufacturerData,
|
||||
advertisement: recordArg,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BluetoothLeManualMatcher implements IDiscoveryMatcher<IBluetoothLeManualEntry> {
|
||||
public id = 'bluetooth-le-tracker-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual BLE tracker entries and known_devices-style BLE addresses.';
|
||||
|
||||
public async matches(inputArg: IBluetoothLeManualEntry): Promise<IDiscoveryMatch> {
|
||||
const address = BluetoothLeTrackerMapper.normalizeAddress(inputArg.address || inputArg.mac || inputArg.macAddress || inputArg.haMac || inputArg.id)
|
||||
|| this.firstDeviceAddress(inputArg);
|
||||
const metadata = inputArg.metadata || {};
|
||||
const matched = Boolean(address || metadata.bluetooth_le_tracker || metadata.bleTracker || inputArg.sourceType === bluetoothLeTrackerSourceType || inputArg.snapshot || inputArg.devices?.length || inputArg.knownDevices?.length || inputArg.trackedDevices?.length);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain BLE tracker setup hints.' };
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
confidence: address ? 'high' : 'medium',
|
||||
reason: address ? 'Manual entry contains a trackable BLE address.' : 'Manual entry contains BLE tracker snapshot or device data.',
|
||||
normalizedDeviceId: inputArg.id || address,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: bluetoothLeTrackerDomain,
|
||||
id: inputArg.id || address,
|
||||
name: inputArg.name || (address ? BluetoothLeTrackerMapper.haMac(address) : 'Bluetooth LE Tracker'),
|
||||
manufacturer: inputArg.manufacturer || 'Bluetooth',
|
||||
model: inputArg.model || 'Bluetooth LE tracker',
|
||||
macAddress: address,
|
||||
metadata: {
|
||||
...metadata,
|
||||
address,
|
||||
haMac: BluetoothLeTrackerMapper.haMac(address),
|
||||
sourceType: bluetoothLeTrackerSourceType,
|
||||
track: inputArg.track,
|
||||
trackBattery: inputArg.trackBattery ?? inputArg.track_battery,
|
||||
knownDevices: inputArg.knownDevices,
|
||||
trackedDevices: inputArg.trackedDevices,
|
||||
devices: inputArg.devices,
|
||||
advertisements: inputArg.advertisements,
|
||||
snapshot: inputArg.snapshot,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private firstDeviceAddress(inputArg: IBluetoothLeManualEntry): string | undefined {
|
||||
for (const device of [...(inputArg.knownDevices || []), ...(inputArg.trackedDevices || []), ...(inputArg.devices || []), ...(inputArg.snapshot?.devices || [])]) {
|
||||
const address = BluetoothLeTrackerMapper.normalizeAddress(device.address || device.mac || device.macAddress || device.haMac);
|
||||
if (address) {
|
||||
return address;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class BluetoothLeTrackerCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'bluetooth-le-tracker-candidate-validator';
|
||||
public description = 'Validate BLE tracker discovery candidates.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const address = BluetoothLeTrackerMapper.normalizeAddress(candidateArg.macAddress || candidateArg.id || metadata.address || metadata.haMac);
|
||||
const text = [candidateArg.name, candidateArg.manufacturer, candidateArg.model].filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase();
|
||||
const hasBleHint = candidateArg.source === 'bluetooth'
|
||||
|| metadata.sourceType === bluetoothLeTrackerSourceType
|
||||
|| metadata.bluetooth_le_tracker === true
|
||||
|| metadata.bleTracker === true
|
||||
|| bleTextHints.some((hintArg) => text.includes(hintArg));
|
||||
const matched = candidateArg.integrationDomain === bluetoothLeTrackerDomain || Boolean(address && hasBleHint);
|
||||
return {
|
||||
matched,
|
||||
confidence: matched && candidateArg.integrationDomain === bluetoothLeTrackerDomain && address ? 'certain' : matched && address ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Candidate has BLE tracker metadata.' : 'Candidate is not a Bluetooth LE tracker device.',
|
||||
normalizedDeviceId: address || candidateArg.id,
|
||||
candidate: matched ? {
|
||||
...candidateArg,
|
||||
integrationDomain: bluetoothLeTrackerDomain,
|
||||
macAddress: address || candidateArg.macAddress,
|
||||
metadata: {
|
||||
...metadata,
|
||||
address,
|
||||
haMac: BluetoothLeTrackerMapper.haMac(address),
|
||||
sourceType: bluetoothLeTrackerSourceType,
|
||||
},
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createBluetoothLeTrackerDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: bluetoothLeTrackerDomain, displayName: 'Bluetooth LE Tracker' })
|
||||
.addMatcher(new BluetoothLeAdvertisementMatcher())
|
||||
.addMatcher(new BluetoothLeManualMatcher())
|
||||
.addValidator(new BluetoothLeTrackerCandidateValidator());
|
||||
};
|
||||
@@ -0,0 +1,468 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity, TEntityPlatform } from '../../core/types.js';
|
||||
import type {
|
||||
IBluetoothLeAdvertisement,
|
||||
IBluetoothLeManualEntry,
|
||||
IBluetoothLeTrackedDevice,
|
||||
IBluetoothLeTrackerConfig,
|
||||
IBluetoothLeTrackerEvent,
|
||||
IBluetoothLeTrackerScannerState,
|
||||
IBluetoothLeTrackerSnapshot,
|
||||
TBluetoothLePresenceState,
|
||||
} from './bluetooth_le_tracker.types.js';
|
||||
import {
|
||||
bluetoothLeTrackerBlePrefix,
|
||||
bluetoothLeTrackerDefaultConsiderHomeSeconds,
|
||||
bluetoothLeTrackerDefaultMinSeenNew,
|
||||
bluetoothLeTrackerDefaultScanIntervalSeconds,
|
||||
bluetoothLeTrackerDefaultTrackBatteryIntervalSeconds,
|
||||
bluetoothLeTrackerDomain,
|
||||
bluetoothLeTrackerSourceType,
|
||||
} from './bluetooth_le_tracker.types.js';
|
||||
|
||||
export class BluetoothLeTrackerMapper {
|
||||
public static toSnapshot(configArg: IBluetoothLeTrackerConfig, connectedArg?: boolean, eventsArg: IBluetoothLeTrackerEvent[] = []): IBluetoothLeTrackerSnapshot {
|
||||
const source = configArg.snapshot;
|
||||
const scanner = this.scannerState(configArg, source?.scanner);
|
||||
const advertisements = this.advertisementsFromConfig(configArg)
|
||||
.map((advertisementArg) => this.normalizeAdvertisement(advertisementArg))
|
||||
.filter((advertisementArg): advertisementArg is IBluetoothLeAdvertisement => Boolean(advertisementArg));
|
||||
const trackedDevices = new Map<string, IBluetoothLeTrackedDevice>();
|
||||
const blockedDevices = new Set<string>();
|
||||
|
||||
const addDevice = (deviceArg: IBluetoothLeTrackedDevice | undefined): void => {
|
||||
const device = this.normalizeDevice(deviceArg);
|
||||
const address = device?.address;
|
||||
if (!device || !address) {
|
||||
return;
|
||||
}
|
||||
if (device.track === false || device.tracked === false) {
|
||||
blockedDevices.add(address);
|
||||
trackedDevices.delete(address);
|
||||
return;
|
||||
}
|
||||
const existing = trackedDevices.get(address);
|
||||
trackedDevices.set(address, this.mergeDevice(existing, device, scanner));
|
||||
};
|
||||
|
||||
for (const device of this.devicesFromConfig(configArg)) {
|
||||
addDevice(device);
|
||||
}
|
||||
|
||||
const advertisementCounts = new Map<string, number>();
|
||||
for (const advertisement of advertisements) {
|
||||
const address = advertisement.address;
|
||||
if (!address) {
|
||||
continue;
|
||||
}
|
||||
advertisementCounts.set(address, (advertisementCounts.get(address) || 0) + 1);
|
||||
if (blockedDevices.has(address)) {
|
||||
continue;
|
||||
}
|
||||
if (trackedDevices.has(address)) {
|
||||
addDevice(this.deviceFromAdvertisement(advertisement, advertisementCounts.get(address)));
|
||||
}
|
||||
}
|
||||
|
||||
if (scanner.trackNewDevices) {
|
||||
for (const advertisement of advertisements) {
|
||||
const address = advertisement.address;
|
||||
if (!address || blockedDevices.has(address) || trackedDevices.has(address)) {
|
||||
continue;
|
||||
}
|
||||
const count = advertisementCounts.get(address) || 0;
|
||||
if (count >= scanner.minSeenNew) {
|
||||
addDevice(this.deviceFromAdvertisement(advertisement, count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const devices = [...trackedDevices.values()].map((deviceArg) => this.withPresence(deviceArg, scanner));
|
||||
return {
|
||||
connected: connectedArg ?? source?.connected ?? this.hasStaticData(configArg),
|
||||
scanner,
|
||||
devices,
|
||||
advertisements,
|
||||
events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg],
|
||||
};
|
||||
}
|
||||
|
||||
public static toDevices(snapshotArg: IBluetoothLeTrackerSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
return snapshotArg.devices
|
||||
.map((deviceArg) => this.normalizeDevice(deviceArg))
|
||||
.filter((deviceArg): deviceArg is IBluetoothLeTrackedDevice => Boolean(deviceArg?.address && deviceArg.tracked !== false && deviceArg.track !== false))
|
||||
.map((deviceArg) => {
|
||||
const updatedAt = this.lastSeenIso(deviceArg) || new Date().toISOString();
|
||||
const presence = this.presenceState(deviceArg, snapshotArg.scanner);
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'presence', capability: 'sensor', name: 'Presence', readable: true, writable: false },
|
||||
{ id: 'tracker_state', capability: 'sensor', name: 'Tracker state', readable: true, writable: false },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'presence', value: presence, updatedAt },
|
||||
{ featureId: 'tracker_state', value: presence, updatedAt },
|
||||
];
|
||||
if (typeof deviceArg.rssi === 'number') {
|
||||
features.push({ id: 'rssi', capability: 'sensor', name: 'RSSI', readable: true, writable: false, unit: 'dBm' });
|
||||
state.push({ featureId: 'rssi', value: deviceArg.rssi, updatedAt });
|
||||
}
|
||||
if (typeof deviceArg.battery === 'number' || deviceArg.trackBattery || deviceArg.track_battery) {
|
||||
features.push({ id: 'battery', capability: 'sensor', name: 'Battery', readable: true, writable: false, unit: '%' });
|
||||
state.push({ featureId: 'battery', value: typeof deviceArg.battery === 'number' ? deviceArg.battery : null, updatedAt });
|
||||
}
|
||||
const lastSeen = this.lastSeenIso(deviceArg);
|
||||
if (lastSeen) {
|
||||
features.push({ id: 'last_seen', capability: 'sensor', name: 'Last seen', readable: true, writable: false });
|
||||
state.push({ featureId: 'last_seen', value: lastSeen, updatedAt });
|
||||
}
|
||||
if (typeof deviceArg.advertisementCount === 'number') {
|
||||
features.push({ id: 'advertisement_count', capability: 'sensor', name: 'Advertisement count', readable: true, writable: false });
|
||||
state.push({ featureId: 'advertisement_count', value: deviceArg.advertisementCount, updatedAt });
|
||||
}
|
||||
return {
|
||||
id: this.deviceId(deviceArg),
|
||||
integrationDomain: bluetoothLeTrackerDomain,
|
||||
name: this.deviceName(deviceArg),
|
||||
protocol: 'unknown',
|
||||
manufacturer: deviceArg.manufacturer || 'Bluetooth',
|
||||
model: deviceArg.model || 'Bluetooth LE tracker',
|
||||
online: presence === 'home',
|
||||
features,
|
||||
state,
|
||||
metadata: {
|
||||
address: deviceArg.address,
|
||||
haMac: this.haMac(deviceArg.address),
|
||||
sourceType: bluetoothLeTrackerSourceType,
|
||||
connectable: deviceArg.connectable,
|
||||
serviceUuids: deviceArg.serviceUuids || deviceArg.serviceUUIDs,
|
||||
serviceData: deviceArg.serviceData,
|
||||
manufacturerData: deviceArg.manufacturerData,
|
||||
trackBattery: deviceArg.trackBattery ?? deviceArg.track_battery,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IBluetoothLeTrackerSnapshot): IIntegrationEntity[] {
|
||||
const usedIds = new Map<string, number>();
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
for (const device of snapshotArg.devices) {
|
||||
const normalizedDevice = this.normalizeDevice(device);
|
||||
if (!normalizedDevice?.address || normalizedDevice.track === false || normalizedDevice.tracked === false) {
|
||||
continue;
|
||||
}
|
||||
const deviceId = this.deviceId(normalizedDevice);
|
||||
const addressSlug = this.slug(normalizedDevice.address);
|
||||
const presence = this.presenceState(normalizedDevice, snapshotArg.scanner);
|
||||
const name = this.deviceName(normalizedDevice);
|
||||
entities.push(this.entity('binary_sensor', `${name} Presence`, deviceId, `bluetooth_le_tracker_presence_${addressSlug}`, presence === 'home' ? 'on' : 'off', usedIds, {
|
||||
deviceClass: 'presence',
|
||||
sourceType: bluetoothLeTrackerSourceType,
|
||||
address: normalizedDevice.address,
|
||||
haMac: this.haMac(normalizedDevice.address),
|
||||
}, true));
|
||||
entities.push(this.entity('sensor', `${name} Tracker State`, deviceId, `bluetooth_le_tracker_state_${addressSlug}`, presence, usedIds, {
|
||||
sourceType: bluetoothLeTrackerSourceType,
|
||||
address: normalizedDevice.address,
|
||||
haMac: this.haMac(normalizedDevice.address),
|
||||
}, true));
|
||||
if (typeof normalizedDevice.rssi === 'number') {
|
||||
entities.push(this.entity('sensor', `${name} RSSI`, deviceId, `bluetooth_le_tracker_rssi_${addressSlug}`, normalizedDevice.rssi, usedIds, { unit: 'dBm' }, presence === 'home'));
|
||||
}
|
||||
if (typeof normalizedDevice.battery === 'number' || normalizedDevice.trackBattery || normalizedDevice.track_battery) {
|
||||
entities.push(this.entity('sensor', `${name} Battery`, deviceId, `bluetooth_le_tracker_battery_${addressSlug}`, typeof normalizedDevice.battery === 'number' ? normalizedDevice.battery : null, usedIds, {
|
||||
deviceClass: 'battery',
|
||||
unit: '%',
|
||||
}, true));
|
||||
}
|
||||
const lastSeen = this.lastSeenIso(normalizedDevice);
|
||||
if (lastSeen) {
|
||||
entities.push(this.entity('sensor', `${name} Last Seen`, deviceId, `bluetooth_le_tracker_last_seen_${addressSlug}`, lastSeen, usedIds, { deviceClass: 'timestamp' }, true));
|
||||
}
|
||||
if (typeof normalizedDevice.advertisementCount === 'number') {
|
||||
entities.push(this.entity('sensor', `${name} Advertisement Count`, deviceId, `bluetooth_le_tracker_advertisement_count_${addressSlug}`, normalizedDevice.advertisementCount, usedIds, undefined, true));
|
||||
}
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static normalizeAddress(valueArg: unknown): string | undefined {
|
||||
if (typeof valueArg !== 'string' || !valueArg.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const withoutPrefix = valueArg.trim().replace(new RegExp(`^${bluetoothLeTrackerBlePrefix}`, 'i'), '');
|
||||
const compact = withoutPrefix.toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||
if (compact.length === 12) {
|
||||
return compact.match(/.{1,2}/g)?.join(':');
|
||||
}
|
||||
if (/^[a-f0-9]{2}([:-][a-f0-9]{2}){5}$/i.test(withoutPrefix)) {
|
||||
return withoutPrefix.toLowerCase().replace(/-/g, ':');
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static haMac(addressArg: unknown): string | undefined {
|
||||
const address = this.normalizeAddress(addressArg);
|
||||
return address ? `${bluetoothLeTrackerBlePrefix}${address.toUpperCase()}` : undefined;
|
||||
}
|
||||
|
||||
public static cleanName(valueArg: unknown): string | undefined {
|
||||
if (typeof valueArg !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const clean = valueArg.replace(/\x00/g, '').trim();
|
||||
return clean || undefined;
|
||||
}
|
||||
|
||||
private static scannerState(configArg: IBluetoothLeTrackerConfig, sourceArg?: Partial<IBluetoothLeTrackerScannerState>): IBluetoothLeTrackerScannerState {
|
||||
return {
|
||||
scanIntervalSeconds: this.numberValue(configArg.scanIntervalSeconds, configArg.interval_seconds, sourceArg?.scanIntervalSeconds) ?? bluetoothLeTrackerDefaultScanIntervalSeconds,
|
||||
trackNewDevices: this.booleanValue(configArg.trackNewDevices, configArg.track_new_devices, sourceArg?.trackNewDevices) ?? true,
|
||||
trackBattery: this.booleanValue(configArg.trackBattery, configArg.track_battery, sourceArg?.trackBattery) ?? false,
|
||||
trackBatteryIntervalSeconds: this.numberValue(configArg.trackBatteryIntervalSeconds, configArg.track_battery_interval, sourceArg?.trackBatteryIntervalSeconds) ?? bluetoothLeTrackerDefaultTrackBatteryIntervalSeconds,
|
||||
considerHomeSeconds: this.numberValue(configArg.considerHomeSeconds, configArg.consider_home, sourceArg?.considerHomeSeconds) ?? bluetoothLeTrackerDefaultConsiderHomeSeconds,
|
||||
minSeenNew: this.numberValue(configArg.minSeenNew, sourceArg?.minSeenNew) ?? bluetoothLeTrackerDefaultMinSeenNew,
|
||||
};
|
||||
}
|
||||
|
||||
private static devicesFromConfig(configArg: IBluetoothLeTrackerConfig): IBluetoothLeTrackedDevice[] {
|
||||
const entries = configArg.manualEntries || [];
|
||||
return [
|
||||
...(configArg.snapshot?.devices || []),
|
||||
...(configArg.knownDevices || []),
|
||||
...(configArg.trackedDevices || []),
|
||||
...(configArg.devices || []),
|
||||
...entries.flatMap((entryArg) => this.devicesFromManualEntry(entryArg)),
|
||||
];
|
||||
}
|
||||
|
||||
private static advertisementsFromConfig(configArg: IBluetoothLeTrackerConfig): IBluetoothLeAdvertisement[] {
|
||||
const entries = configArg.manualEntries || [];
|
||||
return [
|
||||
...(configArg.snapshot?.advertisements || []),
|
||||
...(configArg.advertisements || []),
|
||||
...entries.flatMap((entryArg) => [
|
||||
...(entryArg.snapshot?.advertisements || []),
|
||||
...(entryArg.advertisements || []),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
private static devicesFromManualEntry(entryArg: IBluetoothLeManualEntry): IBluetoothLeTrackedDevice[] {
|
||||
const nested = [
|
||||
...(entryArg.snapshot?.devices || []),
|
||||
...(entryArg.knownDevices || []),
|
||||
...(entryArg.trackedDevices || []),
|
||||
...(entryArg.devices || []),
|
||||
];
|
||||
const ownAddress = this.normalizeAddress(entryArg.address || entryArg.mac || entryArg.macAddress || entryArg.haMac);
|
||||
return ownAddress ? [entryArg, ...nested] : nested;
|
||||
}
|
||||
|
||||
private static normalizeAdvertisement(advertisementArg: IBluetoothLeAdvertisement | undefined): IBluetoothLeAdvertisement | undefined {
|
||||
const address = this.normalizeAddress(advertisementArg?.address || advertisementArg?.mac || advertisementArg?.macAddress || advertisementArg?.haMac);
|
||||
if (!advertisementArg || !address) {
|
||||
return undefined;
|
||||
}
|
||||
const lastSeen = advertisementArg.lastSeen ?? advertisementArg.last_seen ?? advertisementArg.time ?? Date.now();
|
||||
return {
|
||||
...advertisementArg,
|
||||
address,
|
||||
macAddress: address,
|
||||
haMac: this.haMac(address),
|
||||
name: this.cleanName(advertisementArg.name || advertisementArg.localName || advertisementArg.hostName),
|
||||
serviceUuids: advertisementArg.serviceUuids || advertisementArg.serviceUUIDs,
|
||||
lastSeen,
|
||||
sourceType: bluetoothLeTrackerSourceType,
|
||||
metadata: {
|
||||
...advertisementArg.metadata,
|
||||
source: advertisementArg.source,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static normalizeDevice(deviceArg: IBluetoothLeTrackedDevice | undefined): IBluetoothLeTrackedDevice | undefined {
|
||||
const address = this.normalizeAddress(deviceArg?.address || deviceArg?.mac || deviceArg?.macAddress || deviceArg?.haMac);
|
||||
if (!deviceArg || !address) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...deviceArg,
|
||||
address,
|
||||
macAddress: address,
|
||||
haMac: this.haMac(address),
|
||||
name: this.cleanName(deviceArg.name || deviceArg.hostName || deviceArg.hostname),
|
||||
serviceUuids: deviceArg.serviceUuids || deviceArg.serviceUUIDs,
|
||||
sourceType: bluetoothLeTrackerSourceType,
|
||||
};
|
||||
}
|
||||
|
||||
private static deviceFromAdvertisement(advertisementArg: IBluetoothLeAdvertisement, countArg?: number): IBluetoothLeTrackedDevice {
|
||||
return {
|
||||
address: advertisementArg.address,
|
||||
name: advertisementArg.name || advertisementArg.localName || advertisementArg.hostName,
|
||||
track: true,
|
||||
tracked: true,
|
||||
battery: advertisementArg.battery,
|
||||
rssi: advertisementArg.rssi,
|
||||
txPower: advertisementArg.txPower,
|
||||
connectable: advertisementArg.connectable,
|
||||
serviceUuids: advertisementArg.serviceUuids || advertisementArg.serviceUUIDs,
|
||||
serviceData: advertisementArg.serviceData,
|
||||
manufacturerData: advertisementArg.manufacturerData,
|
||||
lastSeen: advertisementArg.lastSeen ?? advertisementArg.last_seen ?? advertisementArg.time,
|
||||
advertisementCount: countArg,
|
||||
raw: advertisementArg.raw,
|
||||
metadata: {
|
||||
...advertisementArg.metadata,
|
||||
discoveredFromAdvertisement: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static mergeDevice(existingArg: IBluetoothLeTrackedDevice | undefined, incomingArg: IBluetoothLeTrackedDevice, scannerArg: IBluetoothLeTrackerScannerState): IBluetoothLeTrackedDevice {
|
||||
if (!existingArg) {
|
||||
return {
|
||||
...incomingArg,
|
||||
track: incomingArg.track ?? true,
|
||||
tracked: incomingArg.tracked ?? incomingArg.track ?? true,
|
||||
trackBattery: incomingArg.trackBattery ?? incomingArg.track_battery ?? scannerArg.trackBattery,
|
||||
};
|
||||
}
|
||||
const latest = this.newerDevice(existingArg, incomingArg);
|
||||
return {
|
||||
...existingArg,
|
||||
...incomingArg,
|
||||
name: incomingArg.name || existingArg.name,
|
||||
manufacturer: incomingArg.manufacturer || existingArg.manufacturer,
|
||||
model: incomingArg.model || existingArg.model,
|
||||
battery: incomingArg.battery ?? existingArg.battery,
|
||||
rssi: incomingArg.rssi ?? existingArg.rssi,
|
||||
txPower: incomingArg.txPower ?? existingArg.txPower,
|
||||
connectable: incomingArg.connectable ?? existingArg.connectable,
|
||||
serviceUuids: incomingArg.serviceUuids || existingArg.serviceUuids,
|
||||
serviceData: incomingArg.serviceData || existingArg.serviceData,
|
||||
manufacturerData: incomingArg.manufacturerData || existingArg.manufacturerData,
|
||||
lastSeen: latest.lastSeen ?? latest.last_seen,
|
||||
last_seen: latest.last_seen,
|
||||
firstSeen: existingArg.firstSeen ?? existingArg.first_seen ?? incomingArg.firstSeen ?? incomingArg.first_seen,
|
||||
advertisementCount: Math.max(existingArg.advertisementCount || 0, incomingArg.advertisementCount || 0) || undefined,
|
||||
track: incomingArg.track ?? existingArg.track ?? true,
|
||||
tracked: incomingArg.tracked ?? existingArg.tracked ?? true,
|
||||
trackBattery: incomingArg.trackBattery ?? incomingArg.track_battery ?? existingArg.trackBattery ?? existingArg.track_battery ?? scannerArg.trackBattery,
|
||||
metadata: {
|
||||
...existingArg.metadata,
|
||||
...incomingArg.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static newerDevice(firstArg: IBluetoothLeTrackedDevice, secondArg: IBluetoothLeTrackedDevice): IBluetoothLeTrackedDevice {
|
||||
const firstTime = this.timestampMillis(firstArg.lastSeen ?? firstArg.last_seen);
|
||||
const secondTime = this.timestampMillis(secondArg.lastSeen ?? secondArg.last_seen);
|
||||
return (secondTime || 0) >= (firstTime || 0) ? secondArg : firstArg;
|
||||
}
|
||||
|
||||
private static withPresence(deviceArg: IBluetoothLeTrackedDevice, scannerArg: IBluetoothLeTrackerScannerState): IBluetoothLeTrackedDevice {
|
||||
return {
|
||||
...deviceArg,
|
||||
state: this.presenceState(deviceArg, scannerArg),
|
||||
};
|
||||
}
|
||||
|
||||
private static presenceState(deviceArg: IBluetoothLeTrackedDevice, scannerArg: IBluetoothLeTrackerScannerState): TBluetoothLePresenceState {
|
||||
const explicit = typeof deviceArg.state === 'string' ? deviceArg.state.toLowerCase() : undefined;
|
||||
if (explicit === 'home' || explicit === 'on' || explicit === 'true') {
|
||||
return 'home';
|
||||
}
|
||||
if (explicit === 'not_home' || explicit === 'off' || explicit === 'false') {
|
||||
return 'not_home';
|
||||
}
|
||||
const lastSeen = this.timestampMillis(deviceArg.lastSeen ?? deviceArg.last_seen);
|
||||
if (!lastSeen) {
|
||||
return typeof deviceArg.rssi === 'number' || typeof deviceArg.battery === 'number' ? 'home' : 'not_home';
|
||||
}
|
||||
return Date.now() - lastSeen <= scannerArg.considerHomeSeconds * 1000 ? 'home' : 'not_home';
|
||||
}
|
||||
|
||||
private static lastSeenIso(deviceArg: IBluetoothLeTrackedDevice): string | undefined {
|
||||
const millis = this.timestampMillis(deviceArg.lastSeen ?? deviceArg.last_seen);
|
||||
return millis ? new Date(millis).toISOString() : undefined;
|
||||
}
|
||||
|
||||
private static timestampMillis(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return valueArg > 1_000_000_000_000 ? valueArg : valueArg > 1_000_000_000 ? valueArg * 1000 : valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string' && valueArg.trim()) {
|
||||
const parsed = Number(valueArg);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return this.timestampMillis(parsed);
|
||||
}
|
||||
const millis = Date.parse(valueArg);
|
||||
return Number.isNaN(millis) ? undefined : millis;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map<string, number>, attributesArg?: Record<string, unknown>, availableArg = true): IIntegrationEntity {
|
||||
const baseId = `${platformArg}.${this.slug(nameArg)}`;
|
||||
const current = usedIdsArg.get(baseId) || 0;
|
||||
usedIdsArg.set(baseId, current + 1);
|
||||
return {
|
||||
id: current ? `${baseId}_${current + 1}` : baseId,
|
||||
uniqueId: uniqueIdArg,
|
||||
integrationDomain: bluetoothLeTrackerDomain,
|
||||
deviceId: deviceIdArg,
|
||||
platform: platformArg,
|
||||
name: nameArg,
|
||||
state: stateArg,
|
||||
attributes: attributesArg,
|
||||
available: availableArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static deviceId(deviceArg: IBluetoothLeTrackedDevice): string {
|
||||
return `${bluetoothLeTrackerDomain}.device.${this.slug(deviceArg.address || deviceArg.macAddress || deviceArg.mac || 'unknown')}`;
|
||||
}
|
||||
|
||||
private static deviceName(deviceArg: IBluetoothLeTrackedDevice): string {
|
||||
return this.cleanName(deviceArg.name || deviceArg.hostName || deviceArg.hostname) || this.haMac(deviceArg.address) || deviceArg.address || 'Bluetooth LE device';
|
||||
}
|
||||
|
||||
private static hasStaticData(configArg: IBluetoothLeTrackerConfig): boolean {
|
||||
return Boolean(configArg.snapshot || configArg.advertisements?.length || configArg.devices?.length || configArg.knownDevices?.length || configArg.trackedDevices?.length || configArg.manualEntries?.length);
|
||||
}
|
||||
|
||||
private static booleanValue(...valuesArg: unknown[]): boolean | undefined {
|
||||
for (const value of valuesArg) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
if (['true', '1', 'yes', 'on'].includes(value.toLowerCase())) return true;
|
||||
if (['false', '0', 'no', 'off'].includes(value.toLowerCase())) return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static numberValue(...valuesArg: unknown[]): number | undefined {
|
||||
for (const value of valuesArg) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'bluetooth_le_tracker';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,160 @@
|
||||
export interface IHomeAssistantBluetoothLeTrackerConfig {
|
||||
// TODO: replace with the TypeScript-native config for bluetooth_le_tracker.
|
||||
export const bluetoothLeTrackerDomain = 'bluetooth_le_tracker';
|
||||
export const bluetoothLeTrackerBlePrefix = 'BLE_';
|
||||
export const bluetoothLeTrackerSourceType = 'bluetooth_le';
|
||||
export const bluetoothLeTrackerDefaultScanIntervalSeconds = 12;
|
||||
export const bluetoothLeTrackerDefaultConsiderHomeSeconds = 180;
|
||||
export const bluetoothLeTrackerDefaultTrackBatteryIntervalSeconds = 86400;
|
||||
export const bluetoothLeTrackerDefaultMinSeenNew = 5;
|
||||
|
||||
export type TBluetoothLePresenceState = 'home' | 'not_home';
|
||||
export type TBluetoothLeTrackerCommandType = 'scan';
|
||||
|
||||
export type TBluetoothLeScanProvider = () => Promise<
|
||||
| IBluetoothLeAdvertisement[]
|
||||
| IBluetoothLeTrackerSnapshot
|
||||
| {
|
||||
snapshot?: IBluetoothLeTrackerSnapshot;
|
||||
devices?: IBluetoothLeTrackedDevice[];
|
||||
advertisements?: IBluetoothLeAdvertisement[];
|
||||
}
|
||||
>;
|
||||
|
||||
export interface IBluetoothLeTrackerConfig {
|
||||
scanIntervalSeconds?: number;
|
||||
interval_seconds?: number;
|
||||
trackNewDevices?: boolean;
|
||||
track_new_devices?: boolean;
|
||||
trackBattery?: boolean;
|
||||
track_battery?: boolean;
|
||||
trackBatteryIntervalSeconds?: number;
|
||||
track_battery_interval?: number;
|
||||
considerHomeSeconds?: number;
|
||||
consider_home?: number;
|
||||
minSeenNew?: number;
|
||||
snapshot?: IBluetoothLeTrackerSnapshot;
|
||||
knownDevices?: IBluetoothLeTrackedDevice[];
|
||||
trackedDevices?: IBluetoothLeTrackedDevice[];
|
||||
devices?: IBluetoothLeTrackedDevice[];
|
||||
advertisements?: IBluetoothLeAdvertisement[];
|
||||
manualEntries?: IBluetoothLeManualEntry[];
|
||||
events?: IBluetoothLeTrackerEvent[];
|
||||
scanProvider?: TBluetoothLeScanProvider;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantBluetoothLeTrackerConfig extends IBluetoothLeTrackerConfig {}
|
||||
|
||||
export interface IBluetoothLeTrackerScannerState {
|
||||
scanIntervalSeconds: number;
|
||||
trackNewDevices: boolean;
|
||||
trackBattery: boolean;
|
||||
trackBatteryIntervalSeconds: number;
|
||||
considerHomeSeconds: number;
|
||||
minSeenNew: number;
|
||||
}
|
||||
|
||||
export interface IBluetoothLeAdvertisement {
|
||||
address?: string;
|
||||
mac?: string;
|
||||
macAddress?: string;
|
||||
haMac?: string;
|
||||
name?: string;
|
||||
localName?: string;
|
||||
hostName?: string;
|
||||
rssi?: number;
|
||||
txPower?: number;
|
||||
connectable?: boolean;
|
||||
serviceUuids?: string[];
|
||||
serviceUUIDs?: string[];
|
||||
serviceData?: Record<string, unknown>;
|
||||
manufacturerData?: Record<string, unknown>;
|
||||
battery?: number;
|
||||
time?: number;
|
||||
lastSeen?: number | string;
|
||||
last_seen?: number | string;
|
||||
source?: string;
|
||||
sourceType?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
raw?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IBluetoothLeTrackedDevice {
|
||||
address?: string;
|
||||
mac?: string;
|
||||
macAddress?: string;
|
||||
haMac?: string;
|
||||
name?: string;
|
||||
hostName?: string;
|
||||
hostname?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
track?: boolean;
|
||||
tracked?: boolean;
|
||||
trackBattery?: boolean;
|
||||
track_battery?: boolean;
|
||||
battery?: number;
|
||||
rssi?: number;
|
||||
txPower?: number;
|
||||
connectable?: boolean;
|
||||
serviceUuids?: string[];
|
||||
serviceUUIDs?: string[];
|
||||
serviceData?: Record<string, unknown>;
|
||||
manufacturerData?: Record<string, unknown>;
|
||||
lastSeen?: number | string;
|
||||
last_seen?: number | string;
|
||||
firstSeen?: number | string;
|
||||
first_seen?: number | string;
|
||||
advertisementCount?: number;
|
||||
state?: TBluetoothLePresenceState | 'on' | 'off' | string;
|
||||
sourceType?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
raw?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IBluetoothLeManualEntry extends IBluetoothLeTrackedDevice {
|
||||
id?: string;
|
||||
knownDevices?: IBluetoothLeTrackedDevice[];
|
||||
trackedDevices?: IBluetoothLeTrackedDevice[];
|
||||
devices?: IBluetoothLeTrackedDevice[];
|
||||
advertisements?: IBluetoothLeAdvertisement[];
|
||||
snapshot?: IBluetoothLeTrackerSnapshot;
|
||||
}
|
||||
|
||||
export interface IBluetoothLeDiscoveryRecord extends IBluetoothLeAdvertisement {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface IBluetoothLeTrackerSnapshot {
|
||||
connected: boolean;
|
||||
scanner: IBluetoothLeTrackerScannerState;
|
||||
devices: IBluetoothLeTrackedDevice[];
|
||||
advertisements: IBluetoothLeAdvertisement[];
|
||||
events: IBluetoothLeTrackerEvent[];
|
||||
}
|
||||
|
||||
export interface IBluetoothLeTrackerCommand {
|
||||
type: TBluetoothLeTrackerCommandType;
|
||||
service: string;
|
||||
target: {
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
snapshot?: IBluetoothLeTrackerSnapshot;
|
||||
devices?: IBluetoothLeTrackedDevice[];
|
||||
advertisements?: IBluetoothLeAdvertisement[];
|
||||
}
|
||||
|
||||
export interface IBluetoothLeTrackerCommandResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface IBluetoothLeTrackerEvent {
|
||||
type?: 'scan_applied' | 'snapshot_updated' | 'unsupported_scan' | string;
|
||||
deviceId?: string;
|
||||
entityId?: string;
|
||||
data?: unknown;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from './bluetooth_le_tracker.classes.integration.js';
|
||||
export * from './bluetooth_le_tracker.classes.client.js';
|
||||
export * from './bluetooth_le_tracker.classes.configflow.js';
|
||||
export * from './bluetooth_le_tracker.discovery.js';
|
||||
export * from './bluetooth_le_tracker.mapper.js';
|
||||
export * from './bluetooth_le_tracker.types.js';
|
||||
|
||||
@@ -11,7 +11,6 @@ import { HomeAssistantAcomaxIntegration } from '../acomax/index.js';
|
||||
import { HomeAssistantActiontecIntegration } from '../actiontec/index.js';
|
||||
import { HomeAssistantActronAirIntegration } from '../actron_air/index.js';
|
||||
import { HomeAssistantAdaxIntegration } from '../adax/index.js';
|
||||
import { HomeAssistantAdguardIntegration } from '../adguard/index.js';
|
||||
import { HomeAssistantAdsIntegration } from '../ads/index.js';
|
||||
import { HomeAssistantAdvantageAirIntegration } from '../advantage_air/index.js';
|
||||
import { HomeAssistantAemetIntegration } from '../aemet/index.js';
|
||||
@@ -47,12 +46,10 @@ import { HomeAssistantAmazonPollyIntegration } from '../amazon_polly/index.js';
|
||||
import { HomeAssistantAmberelectricIntegration } from '../amberelectric/index.js';
|
||||
import { HomeAssistantAmbientNetworkIntegration } from '../ambient_network/index.js';
|
||||
import { HomeAssistantAmbientStationIntegration } from '../ambient_station/index.js';
|
||||
import { HomeAssistantAmcrestIntegration } from '../amcrest/index.js';
|
||||
import { HomeAssistantAmpMotorizationIntegration } from '../amp_motorization/index.js';
|
||||
import { HomeAssistantAmpioIntegration } from '../ampio/index.js';
|
||||
import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js';
|
||||
import { HomeAssistantAnalyticsInsightsIntegration } from '../analytics_insights/index.js';
|
||||
import { HomeAssistantAndroidtvRemoteIntegration } from '../androidtv_remote/index.js';
|
||||
import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js';
|
||||
import { HomeAssistantAnglianWaterIntegration } from '../anglian_water/index.js';
|
||||
import { HomeAssistantAnovaIntegration } from '../anova/index.js';
|
||||
@@ -74,7 +71,6 @@ import { HomeAssistantAquacellIntegration } from '../aquacell/index.js';
|
||||
import { HomeAssistantAqualogicIntegration } from '../aqualogic/index.js';
|
||||
import { HomeAssistantAquostvIntegration } from '../aquostv/index.js';
|
||||
import { HomeAssistantAranetIntegration } from '../aranet/index.js';
|
||||
import { HomeAssistantArcamFmjIntegration } from '../arcam_fmj/index.js';
|
||||
import { HomeAssistantArestIntegration } from '../arest/index.js';
|
||||
import { HomeAssistantArrisTg2492lgIntegration } from '../arris_tg2492lg/index.js';
|
||||
import { HomeAssistantArtsoundIntegration } from '../artsound/index.js';
|
||||
@@ -84,7 +80,6 @@ import { HomeAssistantArwnIntegration } from '../arwn/index.js';
|
||||
import { HomeAssistantAsekoPoolLiveIntegration } from '../aseko_pool_live/index.js';
|
||||
import { HomeAssistantAssistPipelineIntegration } from '../assist_pipeline/index.js';
|
||||
import { HomeAssistantAssistSatelliteIntegration } from '../assist_satellite/index.js';
|
||||
import { HomeAssistantAsuswrtIntegration } from '../asuswrt/index.js';
|
||||
import { HomeAssistantAtagIntegration } from '../atag/index.js';
|
||||
import { HomeAssistantAtenPeIntegration } from '../aten_pe/index.js';
|
||||
import { HomeAssistantAtlanticcityelectricIntegration } from '../atlanticcityelectric/index.js';
|
||||
@@ -136,7 +131,6 @@ import { HomeAssistantBlueprintIntegration } from '../blueprint/index.js';
|
||||
import { HomeAssistantBluesoundIntegration } from '../bluesound/index.js';
|
||||
import { HomeAssistantBluetoothIntegration } from '../bluetooth/index.js';
|
||||
import { HomeAssistantBluetoothAdaptersIntegration } from '../bluetooth_adapters/index.js';
|
||||
import { HomeAssistantBluetoothLeTrackerIntegration } from '../bluetooth_le_tracker/index.js';
|
||||
import { HomeAssistantBmwConnectedDriveIntegration } from '../bmw_connected_drive/index.js';
|
||||
import { HomeAssistantBondIntegration } from '../bond/index.js';
|
||||
import { HomeAssistantBoschAlarmIntegration } from '../bosch_alarm/index.js';
|
||||
@@ -1425,7 +1419,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAcomaxIntegration()
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantActiontecIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantActronAirIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdaxIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdguardIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdvantageAirIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAemetIntegration());
|
||||
@@ -1461,12 +1454,10 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmazonPollyIntegrat
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmberelectricIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmbientNetworkIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmbientStationIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmcrestIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpMotorizationIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpioIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvRemoteIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnovaIntegration());
|
||||
@@ -1488,7 +1479,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAquacellIntegration
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAqualogicIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAquostvIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAranetIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantArcamFmjIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantArestIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantArrisTg2492lgIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantArtsoundIntegration());
|
||||
@@ -1498,7 +1488,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantArwnIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAsekoPoolLiveIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAssistPipelineIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAssistSatelliteIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAsuswrtIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtagIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtenPeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtlanticcityelectricIntegration());
|
||||
@@ -1550,7 +1539,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlueprintIntegratio
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluesoundIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothAdaptersIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothLeTrackerIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBmwConnectedDriveIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBondIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschAlarmIntegration());
|
||||
@@ -2828,14 +2816,20 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||
|
||||
export const generatedHomeAssistantPortCount = 1412;
|
||||
export const generatedHomeAssistantPortCount = 1406;
|
||||
export const handwrittenHomeAssistantPortDomains = [
|
||||
"adguard",
|
||||
"airgradient",
|
||||
"amcrest",
|
||||
"android_ip_webcam",
|
||||
"androidtv",
|
||||
"androidtv_remote",
|
||||
"apcupsd",
|
||||
"arcam_fmj",
|
||||
"asuswrt",
|
||||
"axis",
|
||||
"blebox",
|
||||
"bluetooth_le_tracker",
|
||||
"braviatv",
|
||||
"broadlink",
|
||||
"cast",
|
||||
|
||||
Reference in New Issue
Block a user