feat(devicemanager): prefer higher-priority discovery source when resolving device names and track per-device name source

This commit is contained in:
2026-01-09 14:09:55 +00:00
parent de34e83b3d
commit 4b61ed31bc
3 changed files with 103 additions and 32 deletions

View File

@@ -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();
}
}