feat(devicemanager): prefer higher-priority discovery source when resolving device names and track per-device name source
This commit is contained in:
10
changelog.md
10
changelog.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user