Skip to content

Commit 2cfc41e

Browse files
committed
fix: make serialnumber always defined, and add helper for generating unique serials
1 parent 0111e03 commit 2cfc41e

File tree

3 files changed

+210
-1
lines changed

3 files changed

+210
-1
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { StableDeviceIdGenerator } from '../util.js'
3+
4+
describe('StableDeviceIdGenerator', () => {
5+
test('generates different IDs for different uniqueness keys', () => {
6+
const generator = new StableDeviceIdGenerator()
7+
8+
const id1 = generator.generateId('1234:5678', '/dev/hidraw0')
9+
const id2 = generator.generateId('1234:9999', '/dev/hidraw1')
10+
11+
expect(id1).not.toBe(id2)
12+
expect(id1).toHaveLength(40) // SHA1 hex digest length
13+
expect(id2).toHaveLength(40)
14+
})
15+
16+
test('generates different IDs for same uniqueness key with different paths', () => {
17+
const generator = new StableDeviceIdGenerator()
18+
19+
const id1 = generator.generateId('1234:5678', '/dev/hidraw0')
20+
const id2 = generator.generateId('1234:5678', '/dev/hidraw1')
21+
22+
expect(id1).not.toBe(id2)
23+
})
24+
25+
test('returns same ID for same device path on repeated calls', () => {
26+
const generator = new StableDeviceIdGenerator()
27+
28+
const id1 = generator.generateId('1234:5678', '/dev/hidraw0')
29+
const id2 = generator.generateId('1234:5678', '/dev/hidraw0')
30+
31+
expect(id1).toBe(id2)
32+
})
33+
34+
test('handles missing device path parameter', () => {
35+
const generator = new StableDeviceIdGenerator()
36+
37+
const id1 = generator.generateId('1234:5678')
38+
const id2 = generator.generateId('1234:5678')
39+
40+
// Without path, each call should generate a new ID
41+
expect(id1).not.toBe(id2)
42+
expect(id1).toHaveLength(40)
43+
expect(id2).toHaveLength(40)
44+
})
45+
46+
test('generates stable IDs with counter increment', () => {
47+
const generator = new StableDeviceIdGenerator()
48+
49+
const id1 = generator.generateId('1234:5678', '/dev/hidraw0')
50+
const id2 = generator.generateId('1234:5678', '/dev/hidraw1')
51+
const id3 = generator.generateId('1234:5678', '/dev/hidraw2')
52+
53+
// All should be unique
54+
expect(new Set([id1, id2, id3]).size).toBe(3)
55+
})
56+
57+
test('separate instances have independent state', () => {
58+
const generator1 = new StableDeviceIdGenerator()
59+
const generator2 = new StableDeviceIdGenerator()
60+
61+
const id1 = generator1.generateId('1234:5678', '/dev/hidraw0')
62+
const id2 = generator2.generateId('1234:5678', '/dev/hidraw1')
63+
64+
// Same inputs in different instances should produce same ID
65+
expect(id1).toBe(id2)
66+
})
67+
68+
test('handles multiple devices with same uniqueness key in batch', () => {
69+
const generator = new StableDeviceIdGenerator()
70+
71+
// Simulate 3 identical devices (same vendor/product) with different paths
72+
const ids = [
73+
generator.generateId('1234:5678', '/dev/hidraw0'),
74+
generator.generateId('1234:5678', '/dev/hidraw1'),
75+
generator.generateId('1234:5678', '/dev/hidraw2'),
76+
]
77+
78+
// All should be unique
79+
expect(new Set(ids).size).toBe(3)
80+
81+
// Requesting same path again should return cached ID
82+
expect(generator.generateId('1234:5678', '/dev/hidraw1')).toBe(ids[1])
83+
})
84+
85+
test('handles mixed scenarios with and without device paths', () => {
86+
const generator = new StableDeviceIdGenerator()
87+
88+
const withPath1 = generator.generateId('1234:5678', '/dev/hidraw0')
89+
const withoutPath1 = generator.generateId('1234:5678')
90+
const withPath2 = generator.generateId('1234:5678', '/dev/hidraw1')
91+
const withoutPath2 = generator.generateId('1234:5678')
92+
93+
// All should be unique
94+
expect(new Set([withPath1, withoutPath1, withPath2, withoutPath2]).size).toBe(4)
95+
96+
// Cached path should return same ID
97+
expect(generator.generateId('1234:5678', '/dev/hidraw0')).toBe(withPath1)
98+
})
99+
100+
test('handles undefined device path (same as omitted)', () => {
101+
const generator = new StableDeviceIdGenerator()
102+
103+
const id1 = generator.generateId('1234:5678', undefined)
104+
const id2 = generator.generateId('1234:5678', undefined)
105+
106+
// Should generate different IDs when path is undefined
107+
expect(id1).not.toBe(id2)
108+
})
109+
110+
test('generates valid hex SHA1 hashes', () => {
111+
const generator = new StableDeviceIdGenerator()
112+
113+
const id = generator.generateId('1234:5678', '/dev/hidraw0')
114+
115+
// Should be 40 character hex string
116+
expect(id).toMatch(/^[0-9a-f]{40}$/)
117+
})
118+
119+
test('deduplicates multiple endpoints of same device', () => {
120+
const generator = new StableDeviceIdGenerator()
121+
122+
// Simulate same device with multiple HID endpoints (same path)
123+
const id1 = generator.generateId('1234:5678', '/dev/hidraw0')
124+
const id2 = generator.generateId('1234:5678', '/dev/hidraw0')
125+
const id3 = generator.generateId('1234:5678', '/dev/hidraw0')
126+
127+
expect(id1).toBe(id2)
128+
expect(id2).toBe(id3)
129+
})
130+
131+
test('handles empty uniqueness key', () => {
132+
const generator = new StableDeviceIdGenerator()
133+
134+
const id1 = generator.generateId('', '/dev/hidraw0')
135+
const id2 = generator.generateId('', '/dev/hidraw1')
136+
137+
expect(id1).not.toBe(id2)
138+
expect(id1).toHaveLength(40)
139+
})
140+
141+
test('real-world scenario: 2 identical Stream Decks', () => {
142+
const generator = new StableDeviceIdGenerator()
143+
144+
// Same vendor:product, different USB paths
145+
const deck1 = generator.generateId('4057:96', '/dev/hidraw0')
146+
const deck2 = generator.generateId('4057:96', '/dev/hidraw1')
147+
148+
expect(deck1).not.toBe(deck2)
149+
expect(deck1).toHaveLength(40)
150+
expect(deck2).toHaveLength(40)
151+
152+
// Re-enumerating same devices should return same IDs
153+
expect(generator.generateId('4057:96', '/dev/hidraw0')).toBe(deck1)
154+
expect(generator.generateId('4057:96', '/dev/hidraw1')).toBe(deck2)
155+
})
156+
})

packages/companion-surface-base/src/surface-api/types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ export interface HIDDevice {
1111
vendorId: number
1212
productId: number
1313
path: string
14-
serialNumber: string | undefined
14+
/**
15+
* The serial number of the device.
16+
* This is either the serial number provided by the device, or something generated by Companion to give this device a unique ID
17+
*/
18+
serialNumber: string
1519
manufacturer: string | undefined
1620
product: string | undefined
1721
release: number

packages/companion-surface-base/src/util.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { createHash } from 'node:crypto'
2+
13
export function assertNever(_v: never): void {
24
// Nothing to do
35
}
@@ -15,3 +17,50 @@ export function parseColor(color: string | undefined): RgbColor {
1517

1618
return { r, g, b }
1719
}
20+
21+
/**
22+
* Helper class to generate stable, unique device IDs for devices that lack serial numbers.
23+
* Each instance is scoped to a single batch of devices, ensuring uniqueness within that batch.
24+
*
25+
* @example
26+
* ```typescript
27+
* const generator = new StableDeviceIdGenerator()
28+
* const serial1 = generator.generateId('1234:5678', '/dev/hidraw0')
29+
* const serial2 = generator.generateId('1234:5678', '/dev/hidraw1')
30+
* // serial1 and serial2 will be unique even for the same uniquenessKey
31+
* ```
32+
*/
33+
export class StableDeviceIdGenerator {
34+
readonly #previousForDevicePath = new Map<string, string>()
35+
readonly #returnedIds = new Map<string, string>()
36+
37+
/**
38+
* Generate a stable unique ID for a device within the current batch.
39+
*
40+
* @param uniquenessKey - A string containing device identifiers (e.g., "vendorId:productId")
41+
* @param devicePath - A unique identifier for the device. Typically the device path. This is to ensure that multiple endpoints of the same hid device get the same id.
42+
* @returns A stable unique identifier for the device
43+
*/
44+
generateId(uniquenessKey: string, devicePath?: string): string {
45+
// Generate a complete key
46+
47+
// If there is something cached against the devicePath, use that
48+
const pathCacheKey = `${uniquenessKey}||${devicePath}`
49+
if (devicePath) {
50+
const cached = this.#previousForDevicePath.get(pathCacheKey)
51+
if (cached) return cached
52+
}
53+
54+
// Loop until we find a non-colliding ID
55+
for (let i = 0; ; i++) {
56+
const id = `${uniquenessKey}||${i}`
57+
if (!this.#returnedIds.has(id)) {
58+
const fakeSerial = createHash('sha1').update(id).digest('hex')
59+
60+
this.#returnedIds.set(id, fakeSerial)
61+
this.#previousForDevicePath.set(pathCacheKey, fakeSerial)
62+
return fakeSerial
63+
}
64+
}
65+
}
66+
}

0 commit comments

Comments
 (0)