204 lines
8.6 KiB
TypeScript
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, '');
|