Files
integrations/ts/integrations/squeezebox/squeezebox.discovery.ts
T

204 lines
8.6 KiB
TypeScript

import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
import type { ISqueezeboxDhcpRecord, ISqueezeboxManualEntry, ISqueezeboxMdnsRecord } from './squeezebox.types.js';
import { squeezeboxDefaultHttpPort } from './squeezebox.types.js';
const squeezeboxDomain = 'squeezebox';
const lmsNames = ['squeezebox', 'lyrion', 'logitech media server', 'lms', 'slimserver'];
const lmsMdnsTypes = new Set([
'_squeezebox._tcp',
'_squeezebox-jsonrpc._tcp',
'_squeezebox-server._tcp',
'_lms._tcp',
'_slimserver._tcp',
]);
export class SqueezeboxMdnsMatcher implements IDiscoveryMatcher<ISqueezeboxMdnsRecord> {
public id = 'squeezebox-mdns-match';
public source = 'mdns' as const;
public description = 'Recognize local Lyrion/Logitech Media Server mDNS advertisements.';
public async matches(recordArg: ISqueezeboxMdnsRecord): Promise<IDiscoveryMatch> {
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
const properties = { ...recordArg.txt, ...recordArg.properties };
const name = cleanName(recordArg.name || recordArg.hostname || valueForKey(properties, 'name')) || 'Lyrion Music Server';
const haystack = `${name} ${type} ${valueForKey(properties, 'model') || ''} ${valueForKey(properties, 'server') || ''}`.toLowerCase();
const serviceMatch = lmsMdnsTypes.has(type);
const nameMatch = lmsNames.some((needleArg) => haystack.includes(needleArg));
if (!serviceMatch && !nameMatch) {
return { matched: false, confidence: 'low', reason: 'mDNS record is not an LMS/Squeezebox service.' };
}
const host = recordArg.host || recordArg.addresses?.[0];
const port = recordArg.port || numberString(valueForKey(properties, 'port')) || squeezeboxDefaultHttpPort;
const id = valueForKey(properties, 'uuid') || valueForKey(properties, 'id') || valueForKey(properties, 'mac') || (host ? `${host}:${port}` : name);
return {
matched: true,
confidence: serviceMatch && host ? 'certain' : serviceMatch ? 'high' : 'medium',
reason: serviceMatch ? `mDNS service ${type} is an LMS service.` : 'mDNS metadata contains LMS/Squeezebox hints.',
normalizedDeviceId: id,
candidate: {
source: 'mdns',
integrationDomain: squeezeboxDomain,
id,
host,
port,
name,
manufacturer: 'Lyrion',
model: 'Lyrion Music Server',
metadata: {
mdnsType: type,
txt: properties,
uuid: valueForKey(properties, 'uuid'),
},
},
metadata: { mdnsType: type },
};
}
}
export class SqueezeboxDhcpMatcher implements IDiscoveryMatcher<ISqueezeboxDhcpRecord> {
public id = 'squeezebox-dhcp-match';
public source = 'dhcp' as const;
public description = 'Recognize Squeezebox player DHCP hints that can start LMS setup.';
public async matches(recordArg: ISqueezeboxDhcpRecord): Promise<IDiscoveryMatch> {
const hostname = recordArg.hostname || recordArg.name || '';
const mac = recordArg.macaddress || recordArg.macAddress || '';
const matched = hostname.toLowerCase().startsWith('squeezebox') || normalizeMac(mac).startsWith('000420');
if (!matched) {
return { matched: false, confidence: 'low', reason: 'DHCP record is not a known Squeezebox player hint.' };
}
const host = recordArg.host || recordArg.ipAddress;
const id = normalizeMac(mac) || host || hostname;
return {
matched: true,
confidence: normalizeMac(mac).startsWith('000420') ? 'high' : 'medium',
reason: 'DHCP record matches the Home Assistant Squeezebox player discovery hints.',
normalizedDeviceId: id,
candidate: {
source: 'dhcp',
integrationDomain: squeezeboxDomain,
id,
host,
port: squeezeboxDefaultHttpPort,
name: hostname || 'Squeezebox player',
manufacturer: 'Logitech',
model: 'Squeezebox Player',
macAddress: mac || undefined,
metadata: {
...recordArg.metadata,
playerDiscovery: true,
},
},
};
}
}
export class SqueezeboxManualMatcher implements IDiscoveryMatcher<ISqueezeboxManualEntry> {
public id = 'squeezebox-manual-match';
public source = 'manual' as const;
public description = 'Recognize manual Lyrion Music Server setup entries.';
public async matches(inputArg: ISqueezeboxManualEntry): Promise<IDiscoveryMatch> {
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase();
const matched = Boolean(inputArg.host || inputArg.metadata?.squeezebox || inputArg.metadata?.lms || lmsNames.some((needleArg) => haystack.includes(needleArg)));
if (!matched) {
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain LMS/Squeezebox setup hints.' };
}
const port = inputArg.port || squeezeboxDefaultHttpPort;
const id = inputArg.id || (inputArg.host ? `${inputArg.host}:${port}` : undefined);
return {
matched: true,
confidence: inputArg.host ? 'high' : 'medium',
reason: 'Manual entry can start Lyrion Music Server setup.',
normalizedDeviceId: id,
candidate: {
source: 'manual',
integrationDomain: squeezeboxDomain,
id,
host: inputArg.host,
port,
name: inputArg.name,
manufacturer: inputArg.manufacturer || 'Lyrion',
model: inputArg.model || 'Lyrion Music Server',
metadata: {
...inputArg.metadata,
cliPort: inputArg.cliPort,
https: inputArg.https,
username: inputArg.username ? true : undefined,
password: inputArg.password ? true : undefined,
},
},
};
}
}
export class SqueezeboxCandidateValidator implements IDiscoveryValidator {
public id = 'squeezebox-candidate-validator';
public description = 'Validate LMS/Squeezebox discovery candidates have local setup metadata.';
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
const metadata = candidateArg.metadata || {};
const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase();
const mdnsType = typeof metadata.mdnsType === 'string' ? normalizeMdnsType(metadata.mdnsType) : '';
const matched = candidateArg.integrationDomain === squeezeboxDomain
|| Boolean(metadata.squeezebox || metadata.lms || metadata.playerDiscovery)
|| lmsMdnsTypes.has(mdnsType)
|| lmsNames.some((needleArg) => haystack.includes(needleArg));
return {
matched,
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
reason: matched ? 'Candidate has LMS/Squeezebox metadata.' : 'Candidate is not LMS/Squeezebox.',
normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || squeezeboxDefaultHttpPort}` : undefined),
candidate: matched ? { ...candidateArg, port: candidateArg.port || squeezeboxDefaultHttpPort } : undefined,
};
}
}
export const createSqueezeboxDiscoveryDescriptor = (): DiscoveryDescriptor => {
return new DiscoveryDescriptor({ integrationDomain: squeezeboxDomain, displayName: 'Squeezebox (Lyrion Music Server)' })
.addMatcher(new SqueezeboxMdnsMatcher())
.addMatcher(new SqueezeboxDhcpMatcher())
.addMatcher(new SqueezeboxManualMatcher())
.addValidator(new SqueezeboxCandidateValidator());
};
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');
const cleanName = (valueArg: string | undefined): string | undefined => {
return valueArg
?.replace(/\._squeezebox(?:-jsonrpc|-server)?\._tcp\.local\.?$/i, '')
.replace(/\._lms\._tcp\.local\.?$/i, '')
.replace(/\._slimserver\._tcp\.local\.?$/i, '')
.replace(/\.local\.?$/i, '')
.trim() || undefined;
};
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 numberString = (valueArg: string | undefined): number | undefined => {
if (!valueArg) {
return undefined;
}
const value = Number(valueArg);
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
};
const normalizeMac = (valueArg: string | undefined): string => (valueArg || '').toLowerCase().replace(/[^a-f0-9]/g, '');