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

@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2026-01-09 - 2.1.0 - feat(devicemanager)
prefer higher-priority discovery source when resolving device names and track per-device name source
- Add TNameSource type and NAME_SOURCE_PRIORITY to rank name sources (generic, manual, airplay, chromecast, mdns, dlna, sonos).
- Replace chooseBestName with shouldUpdateName that validates 'real' names and uses source priority when deciding to update a device name.
- Add nameSourceByIp map to track which discovery source provided the current name and persist updates during registration.
- Register devices with an explicit nameSource (e.g. 'mdns', 'dlna', 'sonos', 'manual') and map speaker protocols to appropriate name sources.
- Ensure manual additions use 'manual' source and non-real names default to 'generic'.
- Clear nameSourceByIp entries when devices are removed/disconnected and on shutdown.
## 2026-01-09 - 2.0.0 - BREAKING CHANGE(core) ## 2026-01-09 - 2.0.0 - BREAKING CHANGE(core)
rework core device architecture: consolidate protocols into a protocols/ module, introduce UniversalDevice + factories, and remove many legacy device-specific classes (breaking API changes) rework core device architecture: consolidate protocols into a protocols/ module, introduce UniversalDevice + factories, and remove many legacy device-specific classes (breaking API changes)

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@ecobridge.xyz/devicemanager', name: '@ecobridge.xyz/devicemanager',
version: '2.0.0', version: '2.1.0',
description: 'a device manager for talking to devices on network and over usb' description: 'a device manager for talking to devices on network and over usb'
} }

View File

@@ -51,6 +51,22 @@ const DEFAULT_OPTIONS: Required<IDeviceManagerOptions> = {
retryBaseDelay: 1000, 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 * 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 { function shouldUpdateName(
// If new name is "real" and existing isn't, use new existingSource: TNameSource | undefined,
if (isRealName(newName) && !isRealName(existing)) { newSource: TNameSource,
return newName!; existingName: string | undefined,
} newName: string | undefined
// If existing is "real", keep it ): boolean {
if (isRealName(existing)) { // If new name isn't real, don't update
return existing!; if (!isRealName(newName)) return false;
}
// Both are generic or undefined - prefer new if it exists // If no existing name or source, use new
return newName || existing || 'Unknown Device'; 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 // Devices keyed by IP address for deduplication
private devicesByIp: Map<string, UniversalDevice> = new Map(); 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 options: Required<IDeviceManagerOptions>;
private retryOptions: IRetryOptions; private retryOptions: IRetryOptions;
@@ -153,15 +181,17 @@ export class DeviceManager extends plugins.events.EventEmitter {
name: string | undefined, name: string | undefined,
manufacturer: string | undefined, manufacturer: string | undefined,
model: string | undefined, model: string | undefined,
feature: Feature feature: Feature,
nameSource: TNameSource
): { device: UniversalDevice; isNew: boolean; featureAdded: boolean } { ): { device: UniversalDevice; isNew: boolean; featureAdded: boolean } {
const existing = this.devicesByIp.get(address); const existing = this.devicesByIp.get(address);
if (existing) { if (existing) {
// Update name if new one is better // Update name if new source has higher priority
const betterName = chooseBestName(existing.name, name); const existingSource = this.nameSourceByIp.get(address);
if (betterName !== existing.name) { if (shouldUpdateName(existingSource, nameSource, existing.name, name)) {
(existing as { name: string }).name = betterName; (existing as { name: string }).name = name!;
this.nameSourceByIp.set(address, nameSource);
} }
// Update manufacturer/model if we have better info // Update manufacturer/model if we have better info
@@ -191,6 +221,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
device.addFeature(feature); device.addFeature(feature);
this.devicesByIp.set(address, device); this.devicesByIp.set(address, device);
this.nameSourceByIp.set(address, isRealName(name) ? nameSource : 'generic');
return { device, isNew: true, featureAdded: true }; return { device, isNew: true, featureAdded: true };
} }
@@ -260,7 +291,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
discovered.name, discovered.name,
manufacturer, manufacturer,
model, model,
feature feature,
'mdns'
); );
if (isNew || featureAdded) { if (isNew || featureAdded) {
@@ -287,7 +319,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
discovered.name, discovered.name,
manufacturer, manufacturer,
model, model,
feature feature,
'mdns'
); );
if (isNew || featureAdded) { if (isNew || featureAdded) {
@@ -318,13 +351,22 @@ export class DeviceManager extends plugins.events.EventEmitter {
); );
const modelName = discovered.txtRecords['model'] || discovered.txtRecords['md']; 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( const { device, isNew } = this.registerDevice(
discovered.address, discovered.address,
discovered.port, discovered.port,
discovered.name, discovered.name,
undefined, undefined,
modelName, modelName,
playbackFeature playbackFeature,
speakerNameSource
); );
// Also add volume feature // Also add volume feature
@@ -378,7 +420,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
desc.friendlyName, desc.friendlyName,
desc.manufacturer, desc.manufacturer,
desc.modelName, desc.modelName,
playbackFeature playbackFeature,
'dlna'
); );
if (!device.hasFeature('volume')) { if (!device.hasFeature('volume')) {
@@ -419,7 +462,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
desc.friendlyName, desc.friendlyName,
desc.manufacturer, desc.manufacturer,
desc.modelName, desc.modelName,
playbackFeature playbackFeature,
'sonos' // Sonos has highest naming priority
); );
if (!device.hasFeature('volume')) { if (!device.hasFeature('volume')) {
@@ -441,6 +485,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
// Ignore disconnect errors // Ignore disconnect errors
} }
this.devicesByIp.delete(address); this.devicesByIp.delete(address);
this.nameSourceByIp.delete(address);
this.emit('device:lost', address); this.emit('device:lost', address);
} }
} }
@@ -629,13 +674,15 @@ export class DeviceManager extends plugins.events.EventEmitter {
existing.addFeature(feature); existing.addFeature(feature);
} }
} }
// Update name if better // Update name if better using priority system
const betterName = chooseBestName(existing.name, device.name); const existingSource = this.nameSourceByIp.get(device.address);
if (betterName !== existing.name) { if (shouldUpdateName(existingSource, 'manual', existing.name, device.name)) {
(existing as { name: string }).name = betterName; (existing as { name: string }).name = device.name;
this.nameSourceByIp.set(device.address, 'manual');
} }
} else { } else {
this.devicesByIp.set(device.address, device); this.devicesByIp.set(device.address, device);
this.nameSourceByIp.set(device.address, isRealName(device.name) ? 'manual' : 'generic');
this.emit('device:added', device); this.emit('device:added', device);
} }
} }
@@ -661,7 +708,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
name, name,
undefined, undefined,
undefined, undefined,
feature feature,
'manual'
); );
await device.connect(); await device.connect();
@@ -692,7 +740,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
name, name,
undefined, undefined,
undefined, undefined,
feature feature,
'manual'
); );
await device.connect(); await device.connect();
@@ -719,7 +768,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
options?.name, options?.name,
undefined, undefined,
undefined, undefined,
feature feature,
'manual'
); );
await device.connect(); await device.connect();
@@ -752,7 +802,8 @@ export class DeviceManager extends plugins.events.EventEmitter {
options?.name, options?.name,
undefined, undefined,
undefined, undefined,
feature feature,
'manual'
); );
await device.connect(); 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( const { device } = this.registerDevice(
address, address,
port, port,
options?.name, options?.name,
undefined, undefined,
undefined, undefined,
playbackFeature playbackFeature,
speakerNameSource
); );
if (!device.hasFeature('volume')) { if (!device.hasFeature('volume')) {
@@ -946,6 +1004,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
if (device) { if (device) {
await device.disconnect(); await device.disconnect();
this.devicesByIp.delete(device.address); this.devicesByIp.delete(device.address);
this.nameSourceByIp.delete(device.address);
this.emit('device:removed', id); this.emit('device:removed', id);
return true; return true;
} }
@@ -960,6 +1019,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
if (device) { if (device) {
await device.disconnect(); await device.disconnect();
this.devicesByIp.delete(address); this.devicesByIp.delete(address);
this.nameSourceByIp.delete(address);
this.emit('device:removed', device.id); this.emit('device:removed', device.id);
return true; return true;
} }
@@ -984,6 +1044,7 @@ export class DeviceManager extends plugins.events.EventEmitter {
await this.stopDiscovery(); await this.stopDiscovery();
await this.disconnectAll(); await this.disconnectAll();
this.devicesByIp.clear(); this.devicesByIp.clear();
this.nameSourceByIp.clear();
} }
} }