diff --git a/changelog.md b/changelog.md index cb4003d..3cdcc96 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # 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) rework core device architecture: consolidate protocols into a protocols/ module, introduce UniversalDevice + factories, and remove many legacy device-specific classes (breaking API changes) diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4c718cc..3dcbad2 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { 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' } diff --git a/ts/devicemanager.classes.devicemanager.ts b/ts/devicemanager.classes.devicemanager.ts index 1c9c134..ee53f57 100644 --- a/ts/devicemanager.classes.devicemanager.ts +++ b/ts/devicemanager.classes.devicemanager.ts @@ -51,6 +51,22 @@ const DEFAULT_OPTIONS: Required = { 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 = { + 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 = new Map(); + // Track name source for priority-based naming + private nameSourceByIp: Map = new Map(); + private options: Required; 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(); } }