feat(devicemanager): prefer higher-priority discovery source when resolving device names and track per-device name source
This commit is contained in:
@@ -51,6 +51,22 @@ const DEFAULT_OPTIONS: Required<IDeviceManagerOptions> = {
|
||||
retryBaseDelay: 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Name source priority - higher number = higher priority
|
||||
* Used to determine which discovery method's name should be preferred
|
||||
*/
|
||||
type TNameSource = 'generic' | 'manual' | 'airplay' | 'chromecast' | 'mdns' | 'dlna' | 'sonos';
|
||||
|
||||
const NAME_SOURCE_PRIORITY: Record<TNameSource, number> = {
|
||||
generic: 0, // IP-based placeholder names
|
||||
manual: 1, // Manually added devices
|
||||
airplay: 2, // AirPlay can have generic names
|
||||
chromecast: 3, // Chromecast has decent names
|
||||
mdns: 4, // mDNS for scanners/printers (usually good)
|
||||
dlna: 5, // SSDP/UPnP MediaRenderer (good friendly names)
|
||||
sonos: 6, // Sonos SSDP ZonePlayer (most authoritative)
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a name is a "real" name vs a generic placeholder
|
||||
*/
|
||||
@@ -71,19 +87,28 @@ function isRealName(name: string | undefined): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the best name from multiple options
|
||||
* Choose the best name based on source priority
|
||||
*/
|
||||
function chooseBestName(existing: string | undefined, newName: string | undefined): string {
|
||||
// If new name is "real" and existing isn't, use new
|
||||
if (isRealName(newName) && !isRealName(existing)) {
|
||||
return newName!;
|
||||
}
|
||||
// If existing is "real", keep it
|
||||
if (isRealName(existing)) {
|
||||
return existing!;
|
||||
}
|
||||
// Both are generic or undefined - prefer new if it exists
|
||||
return newName || existing || 'Unknown Device';
|
||||
function shouldUpdateName(
|
||||
existingSource: TNameSource | undefined,
|
||||
newSource: TNameSource,
|
||||
existingName: string | undefined,
|
||||
newName: string | undefined
|
||||
): boolean {
|
||||
// If new name isn't real, don't update
|
||||
if (!isRealName(newName)) return false;
|
||||
|
||||
// If no existing name or source, use new
|
||||
if (!existingName || !existingSource) return true;
|
||||
|
||||
// If existing name isn't real but new is, use new
|
||||
if (!isRealName(existingName)) return true;
|
||||
|
||||
// Both are real names - use priority
|
||||
const existingPriority = NAME_SOURCE_PRIORITY[existingSource] ?? 0;
|
||||
const newPriority = NAME_SOURCE_PRIORITY[newSource] ?? 0;
|
||||
|
||||
return newPriority > existingPriority;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,6 +139,9 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
// Devices keyed by IP address for deduplication
|
||||
private devicesByIp: Map<string, UniversalDevice> = new Map();
|
||||
|
||||
// Track name source for priority-based naming
|
||||
private nameSourceByIp: Map<string, TNameSource> = new Map();
|
||||
|
||||
private options: Required<IDeviceManagerOptions>;
|
||||
private retryOptions: IRetryOptions;
|
||||
|
||||
@@ -153,15 +181,17 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
name: string | undefined,
|
||||
manufacturer: string | undefined,
|
||||
model: string | undefined,
|
||||
feature: Feature
|
||||
feature: Feature,
|
||||
nameSource: TNameSource
|
||||
): { device: UniversalDevice; isNew: boolean; featureAdded: boolean } {
|
||||
const existing = this.devicesByIp.get(address);
|
||||
|
||||
if (existing) {
|
||||
// Update name if new one is better
|
||||
const betterName = chooseBestName(existing.name, name);
|
||||
if (betterName !== existing.name) {
|
||||
(existing as { name: string }).name = betterName;
|
||||
// Update name if new source has higher priority
|
||||
const existingSource = this.nameSourceByIp.get(address);
|
||||
if (shouldUpdateName(existingSource, nameSource, existing.name, name)) {
|
||||
(existing as { name: string }).name = name!;
|
||||
this.nameSourceByIp.set(address, nameSource);
|
||||
}
|
||||
|
||||
// Update manufacturer/model if we have better info
|
||||
@@ -191,6 +221,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
|
||||
device.addFeature(feature);
|
||||
this.devicesByIp.set(address, device);
|
||||
this.nameSourceByIp.set(address, isRealName(name) ? nameSource : 'generic');
|
||||
|
||||
return { device, isNew: true, featureAdded: true };
|
||||
}
|
||||
@@ -260,7 +291,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
discovered.name,
|
||||
manufacturer,
|
||||
model,
|
||||
feature
|
||||
feature,
|
||||
'mdns'
|
||||
);
|
||||
|
||||
if (isNew || featureAdded) {
|
||||
@@ -287,7 +319,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
discovered.name,
|
||||
manufacturer,
|
||||
model,
|
||||
feature
|
||||
feature,
|
||||
'mdns'
|
||||
);
|
||||
|
||||
if (isNew || featureAdded) {
|
||||
@@ -318,13 +351,22 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
);
|
||||
|
||||
const modelName = discovered.txtRecords['model'] || discovered.txtRecords['md'];
|
||||
|
||||
// Map protocol to name source - mDNS speaker protocols
|
||||
const speakerNameSource: TNameSource =
|
||||
protocol === 'sonos' ? 'sonos' :
|
||||
protocol === 'airplay' ? 'airplay' :
|
||||
protocol === 'chromecast' ? 'chromecast' :
|
||||
protocol === 'dlna' ? 'dlna' : 'mdns';
|
||||
|
||||
const { device, isNew } = this.registerDevice(
|
||||
discovered.address,
|
||||
discovered.port,
|
||||
discovered.name,
|
||||
undefined,
|
||||
modelName,
|
||||
playbackFeature
|
||||
playbackFeature,
|
||||
speakerNameSource
|
||||
);
|
||||
|
||||
// Also add volume feature
|
||||
@@ -378,7 +420,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
desc.friendlyName,
|
||||
desc.manufacturer,
|
||||
desc.modelName,
|
||||
playbackFeature
|
||||
playbackFeature,
|
||||
'dlna'
|
||||
);
|
||||
|
||||
if (!device.hasFeature('volume')) {
|
||||
@@ -419,7 +462,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
desc.friendlyName,
|
||||
desc.manufacturer,
|
||||
desc.modelName,
|
||||
playbackFeature
|
||||
playbackFeature,
|
||||
'sonos' // Sonos has highest naming priority
|
||||
);
|
||||
|
||||
if (!device.hasFeature('volume')) {
|
||||
@@ -441,6 +485,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
this.devicesByIp.delete(address);
|
||||
this.nameSourceByIp.delete(address);
|
||||
this.emit('device:lost', address);
|
||||
}
|
||||
}
|
||||
@@ -629,13 +674,15 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
existing.addFeature(feature);
|
||||
}
|
||||
}
|
||||
// Update name if better
|
||||
const betterName = chooseBestName(existing.name, device.name);
|
||||
if (betterName !== existing.name) {
|
||||
(existing as { name: string }).name = betterName;
|
||||
// Update name if better using priority system
|
||||
const existingSource = this.nameSourceByIp.get(device.address);
|
||||
if (shouldUpdateName(existingSource, 'manual', existing.name, device.name)) {
|
||||
(existing as { name: string }).name = device.name;
|
||||
this.nameSourceByIp.set(device.address, 'manual');
|
||||
}
|
||||
} else {
|
||||
this.devicesByIp.set(device.address, device);
|
||||
this.nameSourceByIp.set(device.address, isRealName(device.name) ? 'manual' : 'generic');
|
||||
this.emit('device:added', device);
|
||||
}
|
||||
}
|
||||
@@ -661,7 +708,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
name,
|
||||
undefined,
|
||||
undefined,
|
||||
feature
|
||||
feature,
|
||||
'manual'
|
||||
);
|
||||
|
||||
await device.connect();
|
||||
@@ -692,7 +740,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
name,
|
||||
undefined,
|
||||
undefined,
|
||||
feature
|
||||
feature,
|
||||
'manual'
|
||||
);
|
||||
|
||||
await device.connect();
|
||||
@@ -719,7 +768,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
options?.name,
|
||||
undefined,
|
||||
undefined,
|
||||
feature
|
||||
feature,
|
||||
'manual'
|
||||
);
|
||||
|
||||
await device.connect();
|
||||
@@ -752,7 +802,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
options?.name,
|
||||
undefined,
|
||||
undefined,
|
||||
feature
|
||||
feature,
|
||||
'manual'
|
||||
);
|
||||
|
||||
await device.connect();
|
||||
@@ -792,13 +843,20 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
}
|
||||
);
|
||||
|
||||
// Use protocol as name source for speakers
|
||||
const speakerNameSource: TNameSource =
|
||||
protocol === 'sonos' ? 'sonos' :
|
||||
protocol === 'airplay' ? 'airplay' :
|
||||
protocol === 'chromecast' ? 'chromecast' : 'dlna';
|
||||
|
||||
const { device } = this.registerDevice(
|
||||
address,
|
||||
port,
|
||||
options?.name,
|
||||
undefined,
|
||||
undefined,
|
||||
playbackFeature
|
||||
playbackFeature,
|
||||
speakerNameSource
|
||||
);
|
||||
|
||||
if (!device.hasFeature('volume')) {
|
||||
@@ -946,6 +1004,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
if (device) {
|
||||
await device.disconnect();
|
||||
this.devicesByIp.delete(device.address);
|
||||
this.nameSourceByIp.delete(device.address);
|
||||
this.emit('device:removed', id);
|
||||
return true;
|
||||
}
|
||||
@@ -960,6 +1019,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
if (device) {
|
||||
await device.disconnect();
|
||||
this.devicesByIp.delete(address);
|
||||
this.nameSourceByIp.delete(address);
|
||||
this.emit('device:removed', device.id);
|
||||
return true;
|
||||
}
|
||||
@@ -984,6 +1044,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
|
||||
await this.stopDiscovery();
|
||||
await this.disconnectAll();
|
||||
this.devicesByIp.clear();
|
||||
this.nameSourceByIp.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user