• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rokucommunity / vscode-brightscript-language / 26235216720

21 May 2026 03:18PM UTC coverage: 56.569% (+1.1%) from 55.501%
26235216720

Pull #790

github

web-flow
Merge 73903c9d3 into b9f6aae1a
Pull Request #790: Add filter dropdown to the Devices view

2291 of 4457 branches covered (51.4%)

Branch coverage included in aggregate %.

62 of 65 new or added lines in 3 files covered. (95.38%)

486 existing lines in 6 files now uncovered.

3612 of 5978 relevant lines covered (60.42%)

39.42 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.84
/src/deviceDiscovery/DeviceManager.ts
1
import { EventEmitter } from 'eventemitter3';
1✔
2
import * as vscode from 'vscode';
1✔
3
import { firstBy } from 'thenby';
1✔
4
import type { Disposable } from 'vscode';
5
import { rokuDeploy, DeviceUnreachableError, type DeviceInfoRaw } from 'roku-deploy';
1✔
6
import { util as rokuDebugUtil } from 'roku-debug/dist/util';
1✔
7
import type { GlobalStateManager } from '../GlobalStateManager';
8
import { RokuFinder } from './RokuFinder';
1✔
9
import { NetworkChangeMonitor, getNetworkHash } from './NetworkChangeMonitor';
1✔
10
import { SystemSleepMonitor } from './SystemSleepMonitor';
1✔
11
import { util } from '../util';
1✔
12
import { vscodeContextManager } from '../managers/VscodeContextManager';
1✔
13
import { debounce } from 'lodash';
1✔
14
import { icons } from '../icons';
1✔
15

16
export class DeviceManager {
1✔
17
    // #region constructor
18
    constructor(
19
        private context: vscode.ExtensionContext,
254✔
20
        private globalStateManager: GlobalStateManager,
254✔
21
        private extensionOutputChannel?: vscode.OutputChannel
254✔
22
    ) {
23
        this.networkId = getNetworkHash();
254✔
24

25
        this.setupConfiguration();
254✔
26
        this.setupWindowFocusHandling();
254✔
27
        this.setupMonitors();
254✔
28
        this.initialize();
254✔
29
        this.context.subscriptions.push(this);
254✔
30
    }
31

32
    private setupConfiguration() {
33
        const applyConfig = (event?: vscode.ConfigurationChangeEvent) => {
192✔
34
            let config: any = util.getConfiguration('brightscript') || {};
193!
35

36
            void vscodeContextManager.set('brightscript.deviceDiscovery.enabled', config.deviceDiscovery?.enabled);
193✔
37
            void vscodeContextManager.set('brightscript.hasDefaultDevicePassword', !!this.getDefaultPassword());
193✔
38

39
            //if the `deviceDiscovery.enabled` setting was changed, start or stop monitoring
40
            if (event?.affectsConfiguration('brightscript.deviceDiscovery.enabled')) {
193!
41
                if (this.deviceDiscoveryEnabled) {
×
42
                    //emit that we need a scan (will trigger UI to refresh and show devices as needed when enabled)
43
                    this.setScanNeeded(true);
×
44
                    this.systemSleepMonitor.start();
×
45
                    void this.activateMonitoring();
×
46
                } else {
47
                    this.systemSleepMonitor.stop();
×
48
                    this.deactivateMonitoring();
×
49
                }
50
            }
51

52
            //if the `concealDeviceInfo` setting was changed, refresh the UI (no reload needed)
53
            if (event?.affectsConfiguration('brightscript.deviceDiscovery.concealDeviceInfo')) {
193!
54
                this.emitDevicesChanged();
×
55
            }
56

57
            //if the `includeNonDeveloperDevices` setting was changed, refresh the UI to show/hide devices
58
            if (event?.affectsConfiguration('brightscript.deviceDiscovery.includeNonDeveloperDevices')) {
193✔
59
                this.emitDevicesChanged();
1✔
60
            }
61

62
            //if the `devices` setting was changed, re-apply configured devices and health check them
63
            if (event?.affectsConfiguration('brightscript.devices')) {
193!
UNCOV
64
                this.loadConfiguredDevices().then(() => {
×
UNCOV
65
                    return this.healthCheckAllDevices(false, true);
×
66
                }).catch(() => { });
67
            }
68

69
            //if the `defaultDevicePassword` setting was changed, refresh any device views that rely on it
70
            if (event?.affectsConfiguration('brightscript.defaultDevicePassword')) {
193!
UNCOV
71
                this.emitDevicesChanged();
×
72
            }
73
        };
74
        this.context.subscriptions.push(
192✔
75
            vscode.workspace.onDidChangeConfiguration(applyConfig)
76
        );
77
        applyConfig();
192✔
78
    }
79

80
    private setupWindowFocusHandling() {
81
        this.context.subscriptions.push(
192✔
82
            vscode.window.onDidChangeWindowState((state) => {
UNCOV
83
                if (state.focused) {
×
UNCOV
84
                    this.notifyFocusGained();
×
85
                } else {
UNCOV
86
                    this.notifyFocusLost();
×
87
                }
88
            })
89
        );
90
    }
91

92
    private setupMonitors() {
93
        this.systemSleepMonitor = new SystemSleepMonitor(() => {
192✔
UNCOV
94
            this.setScanNeeded();
×
95
        });
96
        this.networkChangeMonitor = new NetworkChangeMonitor(() => {
192✔
97
            this.networkId = getNetworkHash();
7✔
98

99
            //reset all configured device states to unknown - need to re-verify on new network
100
            for (const entry of this.configuredDevices) {
7✔
101
                entry.lastState = entry.state;
1✔
102
                entry.state = 'unknown';
1✔
103
                entry.stateLastUpdated = Date.now();
1✔
104
            }
105

106
            //clear and reload discovered devices anytime this network changes (state goes with them)
107
            this.discoveredDevices = [];
7✔
108
            this.loadLastSeenDevices();
7✔
109

110
            this.restartRokuFinder();
7✔
111

112
            //this is important for telling the devices view to refresh and health check its devices
113
            this.setScanNeeded();
7✔
114
        });
115
    }
116

117
    private initialize() {
118
        //clear any deviceInfo entries older than our max age
119
        this.globalStateManager.clearExpiredDevices();
192✔
120

121
        // Load configured devices and cached devices (order doesn't matter due to setDevice merge logic)
122
        this.loadConfiguredDevices().catch(() => { });
192✔
123
        this.loadLastSeenDevices();
192✔
124

125
        // Set up event listeners for the RokuFinder
126
        this.setupFinderListeners();
192✔
127

128
        if (this.deviceDiscoveryEnabled) {
192✔
129
            // Sleep monitor runs all the time when enabled (ignores focus state)
130
            this.systemSleepMonitor.start();
12✔
131

132
            this.activateMonitoring().then(() => {
12✔
133
                const lastSeenDeviceIds = this.globalStateManager.getLastSeenDevices(this.networkId);
12✔
134
                if (lastSeenDeviceIds.length === 0) {
12!
135
                    this.refresh();
12✔
136
                } else {
UNCOV
137
                    this.setScanNeeded();
×
138
                }
139
            }).catch((e) => {
UNCOV
140
                console.error(e);
×
141
            });
142
        }
143
    }
144
    // #endregion
145

146
    // Core state and dependencies
147
    private configuredDevices: ConfiguredDeviceEntry[] = [];
254✔
148
    private discoveredDevices: DiscoveredDeviceEntry[] = [];
254✔
149
    private scanNeeded = false;
254✔
150
    private lastUsedDeviceIp: string | undefined = undefined;
254✔
151
    private networkId: string;
152

153
    private emitter = new EventEmitter();
254✔
154
    private systemSleepMonitor: SystemSleepMonitor;
155
    private networkChangeMonitor: NetworkChangeMonitor;
156
    private finder = new RokuFinder(this.globalStateManager, this.makeFinderLogger());
254✔
157

158
    // Health check tracking and cooldowns
159
    private resolveDeviceSequence = new Map<string, number>();
254✔
160
    private readonly DEVICE_INFO_CACHE_MS = 5 * 60 * 1_000; // 5 minutes - cache duration for fetchDeviceInfo
254✔
161
    private readonly FRESH_CACHE_THRESHOLD_MS = 5 * 60 * 1_000; // 5 minutes - cache fresher than this = online on load
254✔
162
    private readonly STALE_DEVICE_AFTER_SCAN_MS = 10_000; // 10 seconds - health check devices with cache older than this after scan
254✔
163
    private readonly OFFLINE_COOLDOWN_MS = 5_000; // 5 seconds - minimum time between resolve attempts for offline devices
254✔
164
    public static readonly HEALTH_CHECK_TIMEOUT_MS = 2_000; // 2 seconds
1✔
165

166
    // Notifications and event debouncing
167
    private readonly DEVICES_CHANGED_DEBOUNCE_MS = 50;
254✔
168
    private deviceOnlineNotifiers = new Map<string, ReturnType<typeof debounce>>();
254✔
169

170
    // Scan state management
171
    private readonly STALE_SCAN_THRESHOLD_MS = 30 * 60 * 1_000; // 30 minutes
254✔
172
    private lastScanDate: Date | null = null;
254✔
173

174
    public on(eventName: 'devices-changed', handler: () => void, disposables?: Disposable[]): () => void;
175
    public on(eventName: 'scan-started', handler: () => void, disposables?: Disposable[]): () => void;
176
    public on(eventName: 'scan-ended', handler: () => void, disposables?: Disposable[]): () => void;
177
    public on(eventName: 'scanNeeded-changed', handler: () => void, disposables?: Disposable[]): () => void;
178
    public on(eventName: string, handler: (payload: any) => void, disposables?: Disposable[]): () => void {
179
        this.emitter.on(eventName, handler);
66✔
180
        const unsubscribe = () => {
66✔
181
            if (this.emitter !== undefined) {
1!
182
                this.emitter.removeListener(eventName, handler);
1✔
183
            }
184
        };
185

186
        disposables?.push({
66✔
187
            dispose: unsubscribe
188
        });
189

190
        return unsubscribe;
66✔
191
    }
192

193
    /**
194
     * Get device by encoded key string.
195
     * Key format: "s:{serialNumber}" or "i:{ip}"
196
     *
197
     * @param key - Encoded device key
198
     * @returns Device with deviceInfo or undefined if not found
199
     */
200
    public getDevice(key: string): RokuDevice | undefined;
201
    /**
202
     * Get device by IP or serial number.
203
     * Returns device with deviceInfo hydrated from cache.
204
     *
205
     * @param lookup - Object with optional ip and/or serialNumber
206
     * @returns Device with deviceInfo or undefined if not found
207
     */
208
    public getDevice(lookup: { ip?: string; serialNumber?: string }): RokuDevice | undefined;
209
    public getDevice(keyOrLookup: string | { ip?: string; serialNumber?: string }): RokuDevice | undefined {
210
        const { configured, discovered } = this.findDeviceEntries(keyOrLookup);
28✔
211
        const device = this.buildMergedDevice(configured, discovered);
28✔
212

213
        // If lookup object with both ip and serialNumber, verify exact match
214
        if (typeof keyOrLookup !== 'string' && keyOrLookup.ip && keyOrLookup.serialNumber && device) {
28!
UNCOV
215
            if (device.ip !== keyOrLookup.ip || device.serialNumber !== keyOrLookup.serialNumber) {
×
UNCOV
216
                return undefined;
×
217
            }
218
        }
219

220
        return device;
28✔
221
    }
222

223
    /**
224
     * Probe an IP address, add it to the discovered devices list if reachable, and return the device.
225
     * Used when user manually enters an IP or before resolving a debug config.
226
     *
227
     * @param ip - The IP address to probe
228
     * @returns The device if reachable, undefined otherwise
229
     */
230
    public async validateAndAddDevice(ip: string): Promise<RokuDevice | undefined> {
231
        this.setDiscoveredDevice(ip, undefined);
9✔
232
        await this.resolveDevice({ ip: ip }, false);
9✔
233
        return this.getDevice({ ip: ip });
9✔
234
    }
235

236
    /**
237
     * Get a list of all roku devices.
238
     * Returns all devices without filtering.
239
     */
240
    public getAllDevices(): RokuDevice[] {
241
        return this.buildAllDevices();
105✔
242
    }
243

244
    /**
245
     * Get all devices filtered for UI display.
246
     * Respects includeNonDeveloperDevices setting.
247
     */
248
    public getDevicesForUI(): RokuDevice[] {
249
        return this.buildAllDevices().filter(d => this.shouldShowDevice(d));
17✔
250
    }
251

252
    /**
253
     * Generate a display name for a device.
254
     * Handles missing device info gracefully (no ugly " - - - " strings).
255
     * @param device - The device to generate a name for
256
     * @param includeIp - Whether to always append IP at the end (default: false, IP only used as fallback)
257
     */
258
    public getDeviceDisplayName(device: RokuDevice, includeIp = false): string {
15✔
259
        // Coerce to a trimmed string, or undefined when the value is missing/blank.
260
        // Whitespace-only values would otherwise pass `Boolean` and render as empty segments.
261
        const clean = (value: unknown): string | undefined => {
35✔
262
            if (value === null || value === undefined || typeof value !== 'string') {
173✔
263
                return undefined;
40✔
264
            }
265
            const str = value.trim();
133✔
266
            return str.length > 0 ? str : undefined;
133✔
267
        };
268

269
        const displayName = clean(device.configuredName) ?? clean(device.deviceInfo['user-device-name']);
35✔
270
        const modelNumber = clean(device.deviceInfo['model-number']);
35✔
271
        const softwareVersion = clean(device.deviceInfo['software-version']);
35✔
272
        const ip = clean(device.ip);
35✔
273

274
        const parts = [
35✔
275
            modelNumber,
276
            displayName,
277
            softwareVersion ? `OS ${softwareVersion}` : undefined
35✔
278
        ].filter(Boolean);
279

280
        if (includeIp && ip) {
35✔
281
            parts.push(ip);
16✔
282
        }
283

284
        return parts.join(' – ') || ip || '';
35✔
285
    }
286

287
    /**
288
     * Generate the label used when showing "host" entries in a quick picker
289
     * @param device the device containing all the info
290
     * @returns a properly formatted host string
291
     */
292
    public getIconPath(device: RokuDevice) {
293
        const hasCache = device.serialNumber && this.hasDeviceCache(device.serialNumber);
11✔
294

295
        if (device.deviceState === 'pending') {
11!
UNCOV
296
            return new vscode.ThemeIcon('circle-small', new vscode.ThemeColor('disabledForeground'));
×
297
        }
298

299
        if (device.deviceState === 'offline') {
11!
UNCOV
300
            const iconId = hasCache ? 'debug-disconnect' : 'warning';
×
UNCOV
301
            return new vscode.ThemeIcon(iconId, new vscode.ThemeColor('disabledForeground'));
×
302
        }
303

304
        if (device.deviceState === 'unknown' && !hasCache) {
11!
UNCOV
305
            return new vscode.ThemeIcon('warning', new vscode.ThemeColor('disabledForeground'));
×
306
        }
307

308
        return icons.getDeviceType(device.deviceInfo);
11✔
309
    }
310

311
    /**
312
     * Build all devices from configuredDevices and discoveredDevices arrays.
313
     * Deduplication by serial number (preferred) or IP (fallback).
314
     */
315
    private buildAllDevices(): RokuDevice[] {
316
        const mergedDevices = new Map<string, RokuDevice>();
122✔
317
        const processedDiscoveredIndices = new Set<number>();
122✔
318

319
        // Process configured devices first, finding matching discovered entries
320
        for (const configured of this.configuredDevices) {
122✔
321
            // Find matching discovered entry by serial, resolvedIp, or host
322
            let discoveredIdx = -1;
78✔
323
            let discovered: DiscoveredDeviceEntry | undefined;
324

325
            if (configured.serialNumber) {
78✔
326
                // Config has serial - ONLY match by serial (serial is primary key)
327
                discoveredIdx = this.discoveredDevices.findIndex(d => d.serialNumber === configured.serialNumber);
64✔
328
            } else {
329
                // Config has no serial - match by IP
330
                if (configured.resolvedIp) {
14!
331
                    discoveredIdx = this.discoveredDevices.findIndex(d => d.ip === configured.resolvedIp);
14✔
332
                }
333
                if (discoveredIdx < 0) {
14✔
334
                    discoveredIdx = this.discoveredDevices.findIndex(d => d.ip === configured.host);
9✔
335
                }
336
            }
337

338
            if (discoveredIdx >= 0) {
78✔
339
                discovered = this.discoveredDevices[discoveredIdx];
45✔
340
                processedDiscoveredIndices.add(discoveredIdx);
45✔
341
            }
342

343
            const device = this.buildMergedDevice(configured, discovered);
78✔
344
            if (device) {
78!
345
                mergedDevices.set(device.key, device);
78✔
346
            }
347
        }
348

349
        // Process discovered-only devices (not already merged via configured)
350
        for (let i = 0; i < this.discoveredDevices.length; i++) {
122✔
351
            if (processedDiscoveredIndices.has(i)) {
96✔
352
                continue;
42✔
353
            }
354

355
            const discovered = this.discoveredDevices[i];
54✔
356
            const device = this.buildMergedDevice(undefined, discovered);
54✔
357
            if (device) {
54!
358
                // Check for duplicate by key
359
                if (mergedDevices.has(device.key)) {
54✔
360
                    continue;
3✔
361
                }
362
                // Only skip by IP if neither device has a serial (serial is primary key)
363
                // Different serials at same IP = different devices
364
                const existingByIp = Array.from(mergedDevices.values()).find(d => d.ip === device.ip);
51✔
365
                if (existingByIp && !device.serialNumber && !existingByIp.serialNumber) {
51!
UNCOV
366
                    continue;
×
367
                }
368
                mergedDevices.set(device.key, device);
51✔
369
            }
370
        }
371

372
        // Convert to array and sort
373
        return Array.from(mergedDevices.values()).sort(
122✔
374
            // Sort by form factor
375
            firstBy<RokuDevice>((a, b) => {
376
                return this.getPriorityForDeviceFormFactor(a.deviceInfo) - this.getPriorityForDeviceFormFactor(b.deviceInfo);
18✔
377
                // Then by name
378
            }).thenBy<RokuDevice>((a, b) => {
379
                const nameA = a.deviceInfo['default-device-name'] || '';
15✔
380
                const nameB = b.deviceInfo['default-device-name'] || '';
15✔
381
                return nameA.localeCompare(nameB);
15✔
382
            }).thenBy<RokuDevice>((a, b) => {
383
                const serialA = a.serialNumber || '';
3✔
384
                const serialB = b.serialNumber || '';
3✔
385
                if (serialA < serialB) {
3!
UNCOV
386
                    return -1;
×
387
                }
388
                if (serialA > serialB) {
3✔
389
                    return 1;
2✔
390
                }
391
                // serial numbers must be equal
392
                return 0;
1✔
393
            })
394
        );
395
    }
396

397
    // #region Device State Management
398
    /**
399
     * Get device state from inline state on entries.
400
     * Priority: discovered > configured > default unknown
401
     * Searches by IP first (if provided), then by serial number
402
     * @param lookup - Device lookup by serial and/or IP
403
     * @returns The device state, defaulting to 'unknown' if not found
404
     */
405
    public getDeviceState(lookup: { serialNumber?: string; ip?: string }): DeviceStateEntry {
406
        // Find discovered entry (by IP first, then by serial)
407
        let discoveredEntry = lookup.ip
133✔
408
            ? this.discoveredDevices.find(d => d.ip === lookup.ip)
126✔
409
            : undefined;
410
        if (!discoveredEntry && lookup.serialNumber) {
133✔
411
            discoveredEntry = this.discoveredDevices.find(d => d.serialNumber === lookup.serialNumber);
13✔
412
        }
413
        if (discoveredEntry?.state) {
133✔
414
            return { state: discoveredEntry.state, lastUpdated: discoveredEntry.stateLastUpdated ?? Date.now() };
91!
415
        }
416

417
        // Find configured entry (by IP first, then by serial)
418
        let configuredEntry = lookup.ip
42✔
419
            ? this.configuredDevices.find(d => d.host === lookup.ip || d.resolvedIp === lookup.ip)
17✔
420
            : undefined;
421
        if (!configuredEntry && lookup.serialNumber) {
42✔
422
            configuredEntry = this.configuredDevices.find(d => d.serialNumber === lookup.serialNumber);
14✔
423
        }
424
        if (configuredEntry?.state) {
42✔
425
            return { state: configuredEntry.state, lastUpdated: configuredEntry.stateLastUpdated ?? Date.now() };
13!
426
        }
427

428
        return { state: 'unknown', lastUpdated: Date.now() };
29✔
429
    }
430

431
    /**
432
     * Set device state directly on entries that match the IP.
433
     * Updates all configured and discovered entries at the given IP.
434
     * When called without explicit state, uses intelligent defaults:
435
     * - If already online, stays online
436
     * - Else checks cache freshness (5 min threshold) to determine online vs unknown
437
     *
438
     * @param lookup - Device lookup by IP (and optionally serial for cache lookup)
439
     * @param state - Explicit state to set, or undefined for intelligent default
440
     */
441
    public setDeviceState(lookup: { serialNumber?: string; ip?: string }, state?: DeviceState): void {
442
        const now = Date.now();
296✔
443
        let resolvedState: DeviceState;
444

445
        //if we were given a state, use it
446
        if (state !== undefined) {
296✔
447
            resolvedState = state;
226✔
448
        } else {
449
            const currentState = this.getDeviceState(lookup).state;
70✔
450
            if (currentState === 'online') {
70✔
451
                resolvedState = 'online';
8✔
452
            } else {
453
                // For non-online devices, check cache freshness
454
                const cached = lookup.serialNumber ? this.globalStateManager.getCachedDevice(lookup.serialNumber) : undefined;
62✔
455
                const isFreshCache = cached && (now - cached.createdAt < this.FRESH_CACHE_THRESHOLD_MS);
62✔
456
                resolvedState = isFreshCache ? 'online' : 'unknown';
62✔
457
            }
458
        }
459

460
        // Update configured entries at this IP that match the serial (or have no serial conflict)
461
        for (const entry of this.configuredDevices) {
296✔
462
            const ipMatches = entry.host === lookup.ip || entry.resolvedIp === lookup.ip;
111✔
463
            // Only update if IP matches AND (no serial conflict OR serials match)
464
            const serialConflict = lookup.serialNumber && entry.serialNumber && entry.serialNumber !== lookup.serialNumber;
111✔
465
            if (ipMatches && !serialConflict) {
111✔
466
                entry.lastState = entry.state;
76✔
467
                entry.state = resolvedState;
76✔
468
                entry.stateLastUpdated = now;
76✔
469
            }
470
        }
471

472
        // Update discovered entries at this IP that match the serial (or have no serial conflict)
473
        for (const entry of this.discoveredDevices) {
296✔
474
            const ipMatches = entry.ip === lookup.ip;
277✔
475
            const serialConflict = lookup.serialNumber && entry.serialNumber && entry.serialNumber !== lookup.serialNumber;
277✔
476
            if (ipMatches && !serialConflict) {
277✔
477
                entry.lastState = entry.state;
229✔
478
                entry.state = resolvedState;
229✔
479
                entry.stateLastUpdated = now;
229✔
480
            }
481
        }
482
    }
483
    // #endregion
484

485
    /**
486
     * Check if a device has cached info (has been successfully resolved before).
487
     * Used by view providers to determine icon: warning (no cache) vs disconnect (has cache).
488
     */
489
    public hasDeviceCache(serialNumber: string): boolean {
490
        return !!this.globalStateManager.getCachedDevice(serialNumber);
29✔
491
    }
492

493
    /**
494
     * Re-scan the network for devices and health-check existing ones
495
     */
496
    public refresh(force = false, doSyntheticDelay = true): boolean {
39✔
497
        this.healthCheckAllDevices(force, doSyntheticDelay).catch(() => { });
26✔
498
        // Block automatic scans when device discovery is disabled
499
        if (!force && !this.deviceDiscoveryEnabled) {
26✔
500
            return false;
3✔
501
        }
502
        return this.discoverAll(force);
23✔
503
    }
504

505
    /**
506
     * Trigger a network scan for devices without health checking existing devices.
507
     * Use this when you just want to discover new devices without verifying existing ones.
508
     * @param force - If true, scan even if deviceDiscovery is disabled
509
     * @returns true if a scan was started, false otherwise
510
     */
511
    public scan(force = false): boolean {
1✔
512
        if (!force && !this.deviceDiscoveryEnabled) {
5✔
513
            return false;
1✔
514
        }
515
        return this.discoverAll(force);
4✔
516
    }
517

518
    /**
519
     * Clear discovered devices from the device list, keeping configured devices.
520
     * Useful for refreshing the network scan without losing user-configured devices.
521
     */
522
    public async clearCurrentDeviceList() {
523
        // Clear discovered devices (ephemeral)
524
        this.discoveredDevices = [];
13✔
525

526
        // Only clear lastUsedDeviceIp if it belonged to a discovered-only device
527
        if (this.lastUsedDeviceIp) {
13!
UNCOV
528
            const stillExists = this.configuredDevices.some(
×
UNCOV
529
                d => d.resolvedIp === this.lastUsedDeviceIp || d.host === this.lastUsedDeviceIp
×
530
            );
UNCOV
531
            if (!stillExists) {
×
UNCOV
532
                this.lastUsedDeviceIp = undefined;
×
533
            }
534
        }
535

536
        //clear the cache for the current list of devices
537
        this.globalStateManager.setLastSeenDevices(this.networkId, []);
13✔
538

539
        await this.healthCheckAllDevices(false, false).catch(() => { });
13✔
540
        this.emitDevicesChanged();
13✔
541

542
    }
543

544
    public clearAllCache() {
545
        // Stop any in-progress scan (finder.stop() emits scan-ended if scanning)
546
        this.finder.stop();
13✔
547

548
        // Clear persisted global state
549
        this.globalStateManager.clearLastSeenDevices();
13✔
550
        this.globalStateManager.clearDeviceCache();
13✔
551
        this.globalStateManager.clearSerialNumberByIpForNetwork();
13✔
552

553
        // Clear all timestamps and per-device state
554
        this.lastScanDate = null;
13✔
555
        this.resolveDeviceSequence.clear();
13✔
556

557
        // Reset configured device states to unknown
558
        for (const entry of this.configuredDevices) {
13✔
NEW
UNCOV
559
            entry.lastState = entry.state;
×
UNCOV
560
            entry.state = 'unknown';
×
UNCOV
561
            entry.stateLastUpdated = Date.now();
×
562
        }
563

564
        // Clear discovered devices (state goes with them)
565
        this.clearCurrentDeviceList().catch(() => { });
13✔
566
    }
567

568
    public async healthCheckDevice(deviceOrLookup: RokuDevice | { ip?: string; serialNumber?: string }, force = false, doSyntheticDelay = true): Promise<boolean> {
35✔
569
        // If already a device object with deviceState, use it directly; otherwise look it up
570
        const device = 'deviceState' in deviceOrLookup
25!
571
            ? deviceOrLookup
572
            : this.getDevice(deviceOrLookup);
573

574
        if (!device) {
25!
UNCOV
575
            return false;
×
576
        }
577

578
        // Cooldown is handled by fetchDeviceInfo cache; force bypasses it
579
        const isHealthy = await this.resolveDevice(device, doSyntheticDelay, force);
25✔
580
        if (!isHealthy && device.isDiscovered) {
25✔
581
            // force a scan if passive scan is permitted
582
            this.refresh(this.deviceDiscoveryEnabled);
7✔
583
        }
584
        return isHealthy;
25✔
585
    }
586

587
    /**
588
     * Validate a developer password against the device at `host`.
589
     *
590
     * Returns:
591
     * - `'ok'` — credentials accepted
592
     * - `'bad-password'` — device reachable, credentials rejected
593
     * - `'unreachable'` — device could not be contacted (transient; don't treat as wrong password)
594
     */
595
    public async validateDevicePassword(host: string, password: string): Promise<PasswordValidationResult> {
596
        try {
5✔
597
            const accepted = await rokuDeploy.validateDeveloperPassword({ host: host, password: password });
5✔
598
            return accepted ? 'ok' : 'bad-password';
2✔
599
        } catch (e) {
600
            if (e instanceof DeviceUnreachableError) {
3✔
601
                return 'unreachable';
1✔
602
            }
603
            // Unexpected response code or any other failure — treat as unreachable so the caller retries/prompts rather than discarding credentials.
604
            return 'unreachable';
2✔
605
        }
606
    }
607

608
    public getLastUsedDeviceIp(): string | undefined {
609
        return this.lastUsedDeviceIp;
4✔
610
    }
611

612
    public setLastUsedDeviceIp(value: string | undefined) {
613
        this.lastUsedDeviceIp = value;
3✔
614
    }
615

616
    public dispose() {
617
        this.deactivateMonitoring();
192✔
618
        this.systemSleepMonitor?.dispose?.();
192!
619
        this.networkChangeMonitor?.dispose?.();
192!
620
        this.finder?.dispose?.();
192!
621
        this.configuredDevices = [];
192✔
622
        this.discoveredDevices = [];
192✔
623
        this.emitter.removeAllListeners();
192✔
624
    }
625

626
    /**
627
     * Is device discovery enabled (i.e. passive scans are permitted)
628
     */
629
    private get deviceDiscoveryEnabled() {
630
        return util.getConfiguration('brightscript')?.deviceDiscovery?.enabled ?? true;
222!
631
    }
632

633
    /**
634
     * Should info messages be shown when new devices are discovered (e.g. "Device found: Roku TV")?
635
     */
636
    private get showInfoMessages() {
637
        return util.getConfiguration('brightscript')?.deviceDiscovery?.showInfoMessages ?? true;
12!
638
    }
639

640
    private get heartbeatLogging() {
641
        return util.getConfiguration('brightscript')?.deviceDiscovery?.heartbeatLogging ?? false;
12!
642
    }
643

644
    private makeFinderLogger(): (msg: string) => void {
645
        return (msg: string) => {
273✔
646
            if (this.heartbeatLogging) {
12!
UNCOV
647
                this.extensionOutputChannel?.appendLine(`[heartbeat] ${msg}`);
×
648
            }
649
        };
650
    }
651

652
    /**
653
     * Should non-developer devices be included in device lists?
654
     */
655
    private get includeNonDeveloperDevices() {
656
        return util.getConfiguration('brightscript')?.deviceDiscovery?.includeNonDeveloperDevices === true;
5!
657
    }
658

659
    /**
660
     * Should this device be shown via public API?
661
     * Filters based on includeNonDeveloperDevices setting.
662
     */
663
    private shouldShowDevice(device: RokuDevice): boolean {
664
        if (this.includeNonDeveloperDevices) {
5✔
665
            return true;
1✔
666
        }
667
        return device?.deviceInfo?.['developer-enabled'] !== 'false';
4!
668
    }
669

670
    /**
671
     * Default password applied to any device that does not have its own configured password.
672
     * Returns undefined when the setting is empty so callers can fall through to their own logic.
673
     */
674
    public getDefaultPassword(): string | undefined {
675
        const value = util.getConfiguration('brightscript')?.defaultDevicePassword;
329!
676
        return typeof value === 'string' && value.length > 0 ? value : undefined;
329✔
677
    }
678

679
    private get timeSinceLastScan(): number {
680
        if (!this.lastScanDate) {
21✔
681
            return Infinity; // Never scanned, so always stale
17✔
682
        }
683
        return Date.now() - this.lastScanDate.getTime();
4✔
684
    }
685

686
    private getPriorityForDeviceFormFactor(deviceInfo: Record<string, any>): number {
687
        if (deviceInfo?.['is-stick'] === 'true') {
36!
688
            return 0;
2✔
689
        }
690
        if (deviceInfo?.['is-tv'] === 'true') {
34!
691
            return 2;
2✔
692
        }
693
        return 1;
32✔
694
    }
695

696
    /**
697
     * Load last seen devices from cache.
698
     * Last seen devices are used to pre-populate the IP→serial mapping.
699
     */
700
    private loadLastSeenDevices(): void {
701
        // Clear discovered devices (ephemeral - reload from network)
702
        this.discoveredDevices = [];
204✔
703

704
        // Load cached devices for current network - add to discoveredDevices (state determined by cache freshness)
705
        const lastSeenDevices = this.globalStateManager.getLastSeenDevices(this.networkId);
204✔
706
        for (const serialNumber of lastSeenDevices) {
204✔
707
            const cached = this.globalStateManager.getCachedDevice(serialNumber);
4✔
708
            if (cached && typeof cached === 'object' && !Array.isArray(cached)) {
4✔
709
                // Get IP from ip-to-serial mapping
710
                const ip = this.globalStateManager.getIpForSerial(serialNumber, this.networkId);
3✔
711
                if (!ip) {
3!
712
                    // No IP mapping found - remove stale entry
UNCOV
713
                    this.globalStateManager.removeLastSeenDevice(this.networkId, serialNumber);
×
UNCOV
714
                    continue;
×
715
                }
716
                // Add to discoveredDevices array (state determined from cache freshness)
717
                this.setDiscoveredDevice(ip, serialNumber);
3✔
718
            } else {
719
                // No cached info - remove stale entry
720
                this.globalStateManager.removeLastSeenDevice(this.networkId, serialNumber);
1✔
721
            }
722
        }
723
    }
724

725
    /**
726
     * Load configured devices from VSCode settings.
727
     * Handles removals (devices no longer in config) and adds/updates.
728
     * Safe to call at startup (removal is no-op when devices array is empty).
729
     * Resolves hostnames to IP addresses using DNS lookup.
730
     */
731
    private async loadConfiguredDevices(): Promise<void> {
732
        // Read config from all VSCode scopes
733
        const inspection = vscode.workspace.getConfiguration('brightscript').inspect<ConfiguredDevice[]>('devices');
200✔
734
        const userDevices = inspection?.globalValue ?? [];
198!
735
        const workspaceDevices = inspection?.workspaceValue ?? [];
198!
736

737
        // Build a map tracking which scopes each device is in
738
        interface ConfiguredDeviceWithScope extends ConfiguredDevice {
739
            configuredIn: ConfigurationScope[];
740
        }
741
        const deviceMap = new Map<string, ConfiguredDeviceWithScope>();
198✔
742

743
        function addDevicesFromScope(devices: ConfiguredDevice[], scope: ConfigurationScope) {
744
            for (const device of devices) {
396✔
745
                if (!device?.host) {
11!
UNCOV
746
                    continue;
×
747
                }
748
                const key = device.serialNumber || device.host;
11✔
749
                const existing = deviceMap.get(key);
11✔
750
                const scopes = existing?.configuredIn ?? [];
11!
751
                if (!scopes.includes(scope)) {
11!
752
                    scopes.push(scope);
11✔
753
                }
754
                deviceMap.set(key, {
11✔
755
                    ...existing,
756
                    ...device,
757
                    configuredIn: scopes
758
                });
759
            }
760
        }
761

762
        addDevicesFromScope(userDevices, 'user');
198✔
763
        addDevicesFromScope(workspaceDevices, 'workspace');
198✔
764

765
        // Clear and rebuild configuredDevices array
766
        this.configuredDevices = [];
198✔
767

768
        // Sort devices by deterministic key for consistent ordering
769
        const sortedDevices = Array.from(deviceMap.values()).sort((a, b) => {
198✔
770
            const keyA = a.serialNumber || a.host;
4!
771
            const keyB = b.serialNumber || b.host;
4!
772
            return keyA.localeCompare(keyB);
4✔
773
        });
774

775
        for (const configured of sortedDevices) {
198✔
776
            // Resolve hostname to IP address (handles both hostnames and IPs)
777
            let resolvedIp: string | undefined;
778
            try {
11✔
779
                resolvedIp = await rokuDebugUtil.dnsLookup(configured.host);
11✔
780
            } catch {
781
                // DNS lookup failed - resolvedIp remains undefined
782
            }
783

784
            const ip = resolvedIp ?? configured.host;
11!
785

786
            this.configuredDevices.push({
11✔
787
                ...configured,
788
                resolvedIp: resolvedIp
789
            });
790

791
            // Set device state using configured serial (not cache - cache might be stale)
792
            this.setDeviceState({ serialNumber: configured.serialNumber, ip: ip });
11✔
793
        }
794

795
        this.emitDevicesChanged();
198✔
796
    }
797

798
    private async resolveDevice(device: RokuDevice | { ip: string }, doSyntheticDelay = true, force = false): Promise<boolean> {
52✔
799
        // Extract serial from device if available (for proper state key management)
800
        const knownSerial = 'serialNumber' in device ? device.serialNumber : undefined;
61✔
801

802
        const currentStateObject = this.getDeviceState({ ip: device.ip, serialNumber: knownSerial });
61✔
803

804
        // Offline cooldown: if device is offline and we recently checked, skip unless forced
805
        // This prevents the loop: healthCheck → resolve → offline → emit → refresh → healthCheck...
806
        const isOffline = currentStateObject.state === 'offline';
61✔
807
        const recentlyCheckedOffline = isOffline && (Date.now() - currentStateObject.lastUpdated < this.OFFLINE_COOLDOWN_MS);
61!
808
        if (!force && recentlyCheckedOffline) {
61!
UNCOV
809
            return false;
×
810
        }
811

812
        // Increment and capture sequence number to handle concurrent refresh calls
813
        // Use IP for sequence tracking (primary key)
814
        const currentSeq = (this.resolveDeviceSequence.get(device.ip) ?? 0) + 1;
61✔
815
        this.resolveDeviceSequence.set(device.ip, currentSeq);
61✔
816

817
        // Get device info from cache or network
818
        let deviceInfo: DeviceInfoRaw | undefined;
819

820
        // Try to find cached data via serial number
821
        const serialForCache = knownSerial ?? this.globalStateManager.getSerialNumberForIp(device.ip, this.networkId);
61✔
822
        const cached = serialForCache ? this.globalStateManager.getCachedDevice(serialForCache) : undefined;
61✔
823
        // Check if the serial was last seen at this IP (don't trust cache if device moved)
824
        const cachedIp = serialForCache ? this.globalStateManager.getIpForSerial(serialForCache, this.networkId) : undefined;
61✔
825
        const cacheIsFresh = cached && (Date.now() - cached.createdAt < this.DEVICE_INFO_CACHE_MS) && cachedIp === device.ip;
61✔
826
        console.log('[TRACE] resolveDevice', device.ip, 'serialForCache=', serialForCache, 'cachedIp=', cachedIp, 'cacheIsFresh=', cacheIsFresh);
61✔
827

828
        // Use cache only if:
829
        // - Not forced
830
        // - Cache is fresh
831
        // - Device is not offline (offline devices should always hit network to check if back online)
832
        if (!force && cacheIsFresh && !isOffline) {
61✔
833
            // Use cached data
834
            deviceInfo = cached.deviceInfo as DeviceInfoRaw;
9✔
835
        } else {
836
            // Set to pending before making network call
837
            // This prevents unnecessary state flicker (online→pending→online) when using cache
838
            if (currentStateObject.state !== 'pending') {
52✔
839
                this.setDeviceState({ ip: device.ip, serialNumber: knownSerial }, 'pending');
46✔
840
                this.emitDevicesChanged();
46✔
841
            }
842

843
            // Fetch fresh data from network
844
            try {
52✔
845
                deviceInfo = await this.fetchDeviceInfo(device.ip, 8060);
52✔
846

847
                if (doSyntheticDelay) {
52✔
848
                    await this.randomDelay(400, 1_000);
33✔
849
                }
850
            } catch {
UNCOV
851
                deviceInfo = undefined;
×
852
            }
853
        }
854

855
        // Only apply result if this is still the latest request for this device
856
        if (this.resolveDeviceSequence.get(device.ip) !== currentSeq) {
61✔
857
            // Stale response - a newer check was started, ignore this result
858
            return !!deviceInfo;
1✔
859
        }
860

861
        if (deviceInfo) {
60✔
862
            // Extract serial from response, fall back to known serial
863
            const serial = deviceInfo['serial-number']?.toString?.() ?? knownSerial;
42✔
864

865
            if (serial) {
42!
866
                // Add to last seen devices (successfully resolved with serial)
867
                this.globalStateManager.addLastSeenDevice(this.networkId, serial);
42✔
868
            }
869

870
            // Update discoveredDevices array (handles mismatch detection internally)
871
            if ('isDiscovered' in device && device.isDiscovered) {
42✔
872
                this.setDiscoveredDevice(device.ip, serial);
34✔
873
            }
874

875
            // Mark any configured devices at this IP with different serials as offline
876
            this.markMismatchedConfiguredDevicesOffline(device.ip, serial);
42✔
877

878
            // Only emit if state actually changed
879
            this.setDeviceState({ ip: device.ip, serialNumber: serial }, 'online');
42✔
880
            this.emitDevicesChanged();
42✔
881
            return true;
42✔
882
        } else {
883
            // Remove from discoveredDevices (ephemeral - offline devices are removed)
884
            this.removeDiscoveredDevice(device.ip);
18✔
885

886
            // Set state to offline on any remaining entries at this IP (configured devices persist)
887
            this.setDeviceState({ ip: device.ip, serialNumber: knownSerial }, 'offline');
18✔
888

889
            this.emitDevicesChanged();
18✔
890
            return false;
18✔
891
        }
892
    }
893

894
    /**
895
     * Check if a newly discovered serial number at an IP represents a mismatch
896
     * with what we currently have stored. Used to trigger config reload when
897
     * a device has changed IPs or a different device is now at a known IP.
898
     *
899
     * Mismatch scenarios:
900
     * - Stored IP→serial map has SerialA for IP1, but got SerialB
901
     * - Discovered device at IP1 had SerialA, but now has SerialB
902
     *
903
     * Note: We intentionally don't check configured device serials here.
904
     * If a user misconfigured a serial, reloading won't fix it and would
905
     * cause an infinite reload loop.
906
     *
907
     * @param ip - The IP address
908
     * @param newSerial - The newly discovered serial number
909
     * @returns true if there's a mismatch that warrants reloading configurations
910
     */
911
    private checkForSerialMismatch(ip: string, newSerial: string | undefined): boolean {
912
        if (!newSerial) {
65✔
913
            // No new serial to compare
914
            return false;
11✔
915
        }
916

917
        // Check what serial we have stored for this IP in the IP→serial map
918
        const storedSerial = this.globalStateManager.getSerialNumberForIp(ip, this.networkId);
54✔
919

920
        if (storedSerial && storedSerial !== newSerial) {
54✔
921
            // Different device is now at this IP
922
            return true;
3✔
923
        }
924

925

926
        // Check if any discovered device at this IP has a different serial
927
        const discoveredDevice = this.discoveredDevices.find(d => d.ip === ip);
51✔
928
        if (discoveredDevice?.serialNumber && discoveredDevice.serialNumber !== newSerial) {
51✔
929
            // Discovered device has a different serial than what's actually at the IP
930
            return true;
1✔
931
        }
932

933
        return false;
50✔
934
    }
935

936
    /**
937
     * Mark configured devices as offline when a different device is found at their IP.
938
     * Note: resolvedIp is only set during DNS resolution in loadConfiguredDevices(),
939
     * not updated here when discovering devices.
940
     */
941
    private markMismatchedConfiguredDevicesOffline(ip: string, serialNumber: string | undefined): void {
942
        for (const entry of this.configuredDevices) {
42✔
943
            const isAtThisIp = entry.host === ip || entry.resolvedIp === ip;
14✔
944
            const hasDifferentSerial = entry.serialNumber && serialNumber && entry.serialNumber !== serialNumber;
14✔
945

946
            if (isAtThisIp && hasDifferentSerial) {
14✔
947
                // Mark the configured entry directly as offline
948
                entry.state = 'offline';
3✔
949
                entry.stateLastUpdated = Date.now();
3✔
950
            }
951
        }
952
    }
953

954
    private async healthCheckAllDevices(force = false, doSyntheticDelay = true): Promise<void> {
4!
955
        // Collect all unique IPs from both sources (same serial at different IPs = different entries to check)
956
        const discoveredIpSet = new Set(this.discoveredDevices.map(entry => entry.ip));
40✔
957
        const allIps = new Set([
40✔
UNCOV
958
            ...this.configuredDevices.map(entry => entry.resolvedIp ?? entry.host),
×
959
            ...discoveredIpSet
960
        ]);
961

962
        if (allIps.size === 0) {
40✔
963
            return;
36✔
964
        }
965

966
        // Set all to pending and emit before async work
967
        for (const ip of allIps) {
4✔
968
            this.setDeviceState({ ip: ip }, 'pending');
6✔
969
        }
970
        this.emitDevicesChanged();
4✔
971

972
        // Health check all devices - if any discovered device is unhealthy, trigger a scan
973
        let needsScan = false;
4✔
974
        await Promise.all([...allIps].map(async (ip) => {
4✔
975
            const isHealthy = await this.resolveDevice({ ip: ip }, doSyntheticDelay, force);
6✔
976
            if (!isHealthy && discoveredIpSet.has(ip)) {
6!
UNCOV
977
                needsScan = true;
×
978
            }
979
        }));
980

981
        if (needsScan) {
4!
UNCOV
982
            this.discoverAll(this.deviceDiscoveryEnabled);
×
983
        }
984
    }
985

986
    /**
987
     * Health check devices that didn't respond to a scan.
988
     * Called after scan-ended. Checks devices whose cache is older than STALE_DEVICE_AFTER_SCAN_MS.
989
     * Iterates over both source arrays to ensure all devices are checked even when
990
     * the same serial exists at multiple IPs.
991
     */
992
    private async healthCheckStaleDevices() {
993
        const now = Date.now();
25✔
994

995
        // Helper to check if a device with given serial is stale
996
        const isStale = (serialNumber: string | undefined): boolean => {
25✔
UNCOV
997
            if (!serialNumber) {
×
UNCOV
998
                return true; // No serial = no cache, consider stale
×
999
            }
UNCOV
1000
            const cached = this.globalStateManager.getCachedDevice(serialNumber);
×
1001
            if (!cached) {
×
1002
                return true;
×
1003
            }
1004
            const cacheAge = now - cached.createdAt;
×
1005
            return cacheAge > this.STALE_DEVICE_AFTER_SCAN_MS;
×
1006
        };
1007

1008
        // Collect unique stale IPs from both source arrays
1009
        const staleIps = new Set([
25✔
1010
            ...this.configuredDevices
UNCOV
1011
                .filter(entry => entry.state !== 'offline' && isStale(entry.serialNumber))
×
UNCOV
1012
                .map(entry => entry.resolvedIp ?? entry.host),
×
1013
            ...this.discoveredDevices
UNCOV
1014
                .filter(entry => entry.state !== 'offline' && isStale(entry.serialNumber))
×
UNCOV
1015
                .map(entry => entry.ip)
×
1016
        ]);
1017

1018
        if (staleIps.size === 0) {
25!
1019
            return;
25✔
1020
        }
1021

1022
        // Cooldown is handled by fetchDeviceInfo cache
UNCOV
1023
        await Promise.all([...staleIps].map(ip => this.resolveDevice({ ip: ip }, false)));
×
1024
    }
1025

1026
    /**
1027
     * Fetch device info from the network. Always makes a network request.
1028
     * Caches the result in globalStateManager for future lookups.
1029
     */
1030
    private async fetchDeviceInfo(ip: string, port: number): Promise<DeviceInfoRaw> {
1031
        try {
54✔
1032
            const info = await rokuDeploy.getDeviceInfo({
54✔
1033
                host: ip,
1034
                remotePort: port,
1035
                timeout: DeviceManager.HEALTH_CHECK_TIMEOUT_MS
1036
            });
1037
            if (info['serial-number']) {
35✔
1038
                this.globalStateManager.setCachedDevice(info['serial-number'], {
34✔
1039
                    serialNumber: info['serial-number'],
1040
                    deviceInfo: info,
1041
                    createdAt: Date.now()
1042
                });
1043
                this.globalStateManager.setSerialNumberForIp(this.networkId, ip, info['serial-number']);
34✔
1044
            }
1045

1046
            return info;
35✔
1047
        } catch (e) {
1048
            console.error(e);
19✔
1049
            return undefined;
19✔
1050
        }
1051
    }
1052

1053
    /**
1054
     * Discover all Roku devices on the network and watch for new ones that connect
1055
     */
1056
    private discoverAll(force: boolean): boolean {
1057
        if (force || this.scanNeeded || this.timeSinceLastScan > this.STALE_SCAN_THRESHOLD_MS) {
29✔
1058
            this.scanNeeded = false;
28✔
1059
            this.lastScanDate = new Date();
28✔
1060
            this.finder.scan();
28✔
1061
            return true;
28✔
1062
        }
1063
        return false;
1✔
1064
    }
1065

1066

1067
    /**
1068
     * Add or update a device in the discoveredDevices array.
1069
     * Handles deduplication by serial number (removes old IP entry if serial matches).
1070
     * Also sets device state using intelligent defaults (cache freshness check).
1071
     */
1072
    private setDiscoveredDevice(ip: string, serialNumber: string | undefined): void {
1073
        // Check for serial mismatch before updating state
1074
        const hasMismatch = this.checkForSerialMismatch(ip, serialNumber);
59✔
1075

1076
        // Serial dedupe: if same serial exists at different IP, remove old entry
1077
        if (serialNumber) {
59✔
1078
            const oldIdx = this.discoveredDevices.findIndex(d => d.ip !== ip && d.serialNumber === serialNumber);
49✔
1079
            if (oldIdx >= 0) {
49✔
1080
                const oldIp = this.discoveredDevices[oldIdx].ip;
4✔
1081
                // Transfer lastUsedDeviceIp to new IP if it was pointing to old IP
1082
                if (this.lastUsedDeviceIp === oldIp) {
4✔
1083
                    this.lastUsedDeviceIp = ip;
1✔
1084
                }
1085
                this.discoveredDevices.splice(oldIdx, 1);
4✔
1086
            }
1087
        }
1088

1089
        // IP dedupe: find existing entry at same IP
1090
        const existingIdx = this.discoveredDevices.findIndex(d => d.ip === ip);
59✔
1091
        const existing = existingIdx >= 0 ? this.discoveredDevices[existingIdx] : undefined;
59✔
1092

1093
        if (existing) {
59✔
1094
            // Update existing entry (preserve state fields so setDeviceState below sees the prior state)
1095
            this.discoveredDevices[existingIdx] = {
36✔
1096
                ...existing,
1097
                ip: ip,
1098
                serialNumber: serialNumber ?? existing.serialNumber
108!
1099
            };
1100
        } else {
1101
            // Add new entry
1102
            this.discoveredDevices.push({
23✔
1103
                ip: ip,
1104
                serialNumber: serialNumber
1105
            });
1106
        }
1107

1108
        // Set device state using intelligent defaults (preserves existing online state or uses cache freshness)
1109
        this.setDeviceState({ serialNumber: serialNumber, ip: ip });
59✔
1110

1111
        // If a different device is now at this IP, reload configurations
1112
        if (hasMismatch) {
59✔
1113
            this.loadConfiguredDevices().catch(() => { });
2✔
1114
        }
1115
    }
1116

1117
    /**
1118
     * Remove a discovered device by IP. Clears from discoveredDevices array,
1119
     * clears lastUsedDeviceIp if it matches, and removes from lastSeenDevices cache.
1120
     */
1121
    private removeDiscoveredDevice(ip: string): void {
1122
        // Find the device first to get its serial number
1123
        const idx = this.discoveredDevices.findIndex(d => d.ip === ip);
23✔
1124
        if (idx < 0) {
23✔
1125
            return;
4✔
1126
        }
1127

1128
        const device = this.discoveredDevices[idx];
19✔
1129
        this.discoveredDevices.splice(idx, 1);
19✔
1130

1131
        // Clear lastUsedDeviceIp if it matches
1132
        if (this.lastUsedDeviceIp === ip) {
19✔
1133
            this.lastUsedDeviceIp = undefined;
1✔
1134
        }
1135

1136
        // Remove from lastSeenDevices if we have a serial
1137
        if (device?.serialNumber) {
19!
1138
            this.globalStateManager.removeLastSeenDevice(this.networkId, device.serialNumber);
10✔
1139
        }
1140
    }
1141

1142
    /**
1143
     * Find configured and discovered device entries by key or lookup criteria.
1144
     * Key format: "s:{serialNumber}" or "i:{ip}"
1145
     * Lookup format: { ip?: string; serialNumber?: string }
1146
     */
1147
    private findDeviceEntries(keyOrLookup: string | { ip?: string; serialNumber?: string }): {
1148
        configured: ConfiguredDeviceEntry | undefined;
1149
        discovered: DiscoveredDeviceEntry | undefined;
1150
    } {
1151
        let configured: ConfiguredDeviceEntry | undefined;
1152
        let discovered: DiscoveredDeviceEntry | undefined;
1153

1154
        if (typeof keyOrLookup === 'string') {
28✔
1155
            // Decode encoded key
1156
            const key = keyOrLookup;
10✔
1157
            if (key.startsWith('s:')) {
10✔
1158
                const serial = key.slice(2);
3✔
1159
                if (serial) {
3✔
1160
                    configured = this.configuredDevices.find(c => c.serialNumber === serial);
2✔
1161
                    discovered = this.discoveredDevices.find(d => d.serialNumber === serial);
2✔
1162
                }
1163
            } else if (key.startsWith('i:')) {
7✔
1164
                const ip = key.slice(2);
5✔
1165
                if (ip) {
5✔
1166
                    configured = this.configuredDevices.find(c => c.resolvedIp === ip || c.host === ip);
4!
1167
                    discovered = this.discoveredDevices.find(d => d.ip === ip);
4✔
1168
                }
1169
            }
1170
        } else {
1171
            // Lookup object
1172
            const lookup = keyOrLookup;
18✔
1173

1174
            if (lookup.serialNumber) {
18✔
1175
                configured = this.configuredDevices.find(c => c.serialNumber === lookup.serialNumber);
3✔
1176
                discovered = this.discoveredDevices.find(d => d.serialNumber === lookup.serialNumber);
3✔
1177
            }
1178

1179
            if (lookup.ip) {
18✔
1180
                if (!configured) {
15!
1181
                    configured = this.configuredDevices.find(c => c.resolvedIp === lookup.ip || c.host === lookup.ip);
15✔
1182
                }
1183
                if (!discovered) {
15!
1184
                    discovered = this.discoveredDevices.find(d => d.ip === lookup.ip);
15✔
1185
                }
1186
            }
1187
        }
1188

1189
        return { configured: configured, discovered: discovered };
28✔
1190
    }
1191

1192
    /**
1193
     * Build a merged RokuDevice from configured and discovered entries.
1194
     * At least one of configured or discovered must be provided.
1195
     */
1196
    private buildMergedDevice(
1197
        configuredEntry: ConfiguredDeviceEntry | undefined,
1198
        discoveredEntry: DiscoveredDeviceEntry | undefined
1199
    ): RokuDevice | undefined {
1200
        if (!configuredEntry && !discoveredEntry) {
160✔
1201
            return undefined;
16✔
1202
        }
1203

1204
        // Determine IP: discovered > resolvedIp > host
1205
        let ip: string;
1206
        if (discoveredEntry) {
144✔
1207
            ip = discoveredEntry.ip;
110✔
1208
        } else if (configuredEntry?.resolvedIp) {
34!
1209
            ip = configuredEntry.resolvedIp;
31✔
1210
        } else {
1211
            ip = configuredEntry.host;
3✔
1212
        }
1213

1214
        // Determine serial: configured > discovered > cache
1215
        // Configured is user's explicit config, discovered is fresh network data,
1216
        // cache is fallback for initial load before discovery runs
1217
        const serialNumber = configuredEntry?.serialNumber ??
144✔
1218
            discoveredEntry?.serialNumber ??
240✔
1219
            this.globalStateManager.getSerialNumberForIp(ip, this.networkId);
1220

1221
        // Determine state: discovered > configured > unknown (discovered is ground truth)
1222
        const deviceState = discoveredEntry?.state ?? configuredEntry?.state ?? 'unknown';
144!
1223
        // Determine previous state: discovered > configured > unknown (discovered is ground truth)
1224
        const lastState = discoveredEntry?.lastState ?? configuredEntry?.lastState ?? 'unknown';
144✔
1225

1226
        // Build key
1227
        const key = serialNumber ? `s:${serialNumber}` : `i:${ip}`;
144✔
1228

1229
        // Hydrate deviceInfo from cache
1230
        const cached = serialNumber ? this.globalStateManager.getCachedDevice(serialNumber) : undefined;
144✔
1231

1232
        return {
144✔
1233
            ip: ip,
1234
            serialNumber: serialNumber,
1235
            key: key,
1236
            deviceState: deviceState,
1237
            lastDeviceState: lastState,
1238
            deviceInfo: cached?.deviceInfo ?? {},
864✔
1239
            isDiscovered: !!discoveredEntry,
1240
            isConfigured: !!configuredEntry,
1241
            configuredIn: configuredEntry?.configuredIn,
432✔
1242
            configuredName: configuredEntry?.name,
432✔
1243
            configuredPassword: configuredEntry?.password ?? this.getDefaultPassword()
864✔
1244
        };
1245
    }
1246

1247
    /**
1248
     * Handle device-online event from RokuFinder.
1249
     * Health checks the device if focused and no cache, and shows notification if enabled.
1250
     */
1251
    private handleDeviceOnline(ip: string, serialNumber?: string): void {
1252
        // Use provided serial, fall back to IP→serial mapping if not provided
1253
        const actualSerial = serialNumber ?? this.globalStateManager.getSerialNumberForIp(ip, this.networkId);
12✔
1254

1255
        // Health check if VS Code is focused and device has no cache
1256
        const hasCache = actualSerial ? this.hasDeviceCache(actualSerial) : false;
12✔
1257
        if (vscode.window.state.focused && !hasCache) {
12✔
1258
            this.resolveUncachedDiscoveredDevices().catch(() => { });
4✔
1259
        }
1260

1261
        if (!this.showInfoMessages) {
12✔
1262
            return;
8✔
1263
        }
1264

1265
        // Get cached device directly from globalStateManager
1266
        const cachedDevice = actualSerial
4!
1267
            ? this.globalStateManager.getCachedDevice(actualSerial)
1268
            : undefined;
1269

1270
        // Get display name from cache
1271
        const fallbackName = actualSerial ? `${ip} (${actualSerial})` : ip;
4!
1272
        const displayName = cachedDevice?.deviceInfo?.['default-device-name'] ?? fallbackName;
4✔
1273
        const notifierId = actualSerial ?? ip;
4!
1274

1275
        if (!this.deviceOnlineNotifiers.has(notifierId)) {
4!
1276
            this.deviceOnlineNotifiers.set(notifierId, debounce((name: string) => {
4✔
1277
                this.deviceOnlineNotifiers.delete(notifierId);
4✔
1278
                void util.showTimedNotification(`Device Online: ${name}`);
4✔
1279
            }, 500));
1280
        }
1281
        this.deviceOnlineNotifiers.get(notifierId)(displayName);
4✔
1282
    }
1283

1284
    private async activateMonitoring() {
1285
        this.networkChangeMonitor.start();
12✔
1286
        await this.startRokuFinder();
12✔
1287
    }
1288

1289
    private deactivateMonitoring() {
1290
        this.networkChangeMonitor.stop();
192✔
1291
        this.stopRokuFinder();
192✔
1292
    }
1293

1294
    /**
1295
     * Set up event listeners for the RokuFinder.
1296
     * This must be called regardless of deviceDiscoveryEnabled so that
1297
     * active scan responses are processed.
1298
     */
1299
    private setupFinderListeners() {
1300
        this.finder.removeAllListeners();
199✔
1301
        this.finder.on('found', (ip: string, options?: { serialNumber?: string }) => {
199✔
1302
            this.setDiscoveredDevice(ip, options?.serialNumber);
1!
1303
            this.emitDevicesChanged();
1✔
1304
        });
1305

1306
        this.finder.on('device-online', (ip: string, serialNumber?: string) => {
199✔
UNCOV
1307
            this.handleDeviceOnline(ip, serialNumber);
×
1308
        });
1309

1310
        this.finder.on('lost', (ip: string) => {
199✔
UNCOV
1311
            this.removeDiscoveredDevice(ip);
×
UNCOV
1312
            this.emitDevicesChanged();
×
1313
        });
1314

1315
        // Forward scan events from RokuFinder
1316
        this.finder.on('scan-started', () => {
199✔
1317
            this.emitter.emit('scan-started');
25✔
1318
        });
1319

1320
        this.finder.on('scan-ended', () => {
199✔
1321
            this.emitter.emit('scan-ended');
25✔
1322
            // Health check devices that didn't respond to the scan (stale cache)
1323
            this.healthCheckStaleDevices().catch(() => { });
25✔
1324
        });
1325
    }
1326

1327
    /**
1328
     * Restart the RokuFinder to rebind UDP sockets to new network interfaces.
1329
     * Called when network changes to ensure SSDP can communicate on the new network.
1330
     */
1331
    private restartRokuFinder() {
1332
        // Keep reference to old finder for delayed disposal
1333
        const oldFinder = this.finder;
7✔
1334

1335
        // Create new finder instance
1336
        this.finder = new RokuFinder(this.globalStateManager, this.makeFinderLogger());
7✔
1337

1338
        // Re-attach event listeners
1339
        this.setupFinderListeners();
7✔
1340

1341
        // Dispose old finder
1342
        oldFinder?.dispose();
7!
1343

1344
        // Restart if device discovery is enabled
1345
        if (this.deviceDiscoveryEnabled) {
7!
UNCOV
1346
            this.startRokuFinder().catch((e) => {
×
UNCOV
1347
                console.error('Failed to restart RokuFinder:', e);
×
1348
            });
1349
        }
1350
    }
1351

1352
    /**
1353
     * Start listening for passive SSDP announcements from Roku devices
1354
     */
1355
    private async startRokuFinder() {
1356
        await this.finder.start();
12✔
1357
        const ts = new Date().toLocaleTimeString();
12✔
1358
        this.makeFinderLogger()(`[${ts}] RokuFinder started — passive ssdp:alive monitoring active`);
12✔
1359
    }
1360

1361
    private stopRokuFinder() {
1362
        this.finder.stop();
192✔
1363
    }
1364

1365
    private notifyFocusGained() {
1366
        this.networkChangeMonitor.start();
1✔
1367
        // Resolve any discovered devices without cache that appeared while unfocused
1368
        this.resolveUncachedDiscoveredDevices().catch(() => { });
1✔
1369
    }
1370

1371
    /**
1372
     * Health check discovered devices that don't have cached info.
1373
     * Called on focus gain to resolve devices that appeared while VS Code was unfocused.
1374
     */
1375
    private async resolveUncachedDiscoveredDevices(): Promise<void> {
1376
        const uncached = this.discoveredDevices.filter(entry => {
9✔
1377
            return !entry.serialNumber || !this.hasDeviceCache(entry.serialNumber);
14✔
1378
        });
1379

1380
        if (uncached.length === 0) {
9✔
1381
            return;
3✔
1382
        }
1383

1384
        // Health check each uncached device in parallel
1385
        await Promise.all(
6✔
1386
            uncached.map(entry => this.healthCheckDevice({ ip: entry.ip, serialNumber: entry.serialNumber }, false, false).catch(() => { }))
10✔
1387
        );
1388
    }
1389

1390
    private notifyFocusLost() {
UNCOV
1391
        this.networkChangeMonitor.stop();
×
1392
    }
1393

1394
    /**
1395
     * Set the flag indicating a scan is needed. Emits 'scanNeeded-changed' event
1396
     * when the flag flips from false to true.
1397
     */
1398
    private setScanNeeded(force = false): void {
15✔
1399
        if (!this.scanNeeded || force) {
15✔
1400
            this.scanNeeded = true;
13✔
1401
            this.emitter.emit('scanNeeded-changed');
13✔
1402
        }
1403
    }
1404

1405
    private emitDevicesChanged = throttleBounce(() => {
254✔
1406
        this.emitter.emit('devices-changed');
265✔
1407
    }, this.DEVICES_CHANGED_DEBOUNCE_MS);
1408

1409
    private async randomDelay(min: number, max: number) {
1410
        const randomness = Math.random() * ((max - min) + min);
9✔
1411
        await util.sleep(randomness);
9✔
1412
    }
1413
}
1414

1415
export type DeviceState = 'offline' | 'unknown' | 'pending' | 'online';
1416

1417
export type PasswordValidationResult = 'ok' | 'bad-password' | 'unreachable';
1418

1419
export type ConfigurationScope = 'user' | 'workspace';
1420

1421
/**
1422
 * User-configured device from settings (brightscript.devices)
1423
 */
1424
export interface ConfiguredDevice {
1425
    host: string;
1426
    name?: string;
1427
    serialNumber?: string;
1428
    password?: string;
1429
}
1430

1431
/**
1432
 * Internal: configured device from settings
1433
 * Extends the raw settings shape with runtime tracking fields.
1434
 * Persists even when device goes offline.
1435
 */
1436
interface ConfiguredDeviceEntry extends ConfiguredDevice {
1437
    /**
1438
     * IP from DNS lookup (updated on resolution)
1439
     */
1440
    resolvedIp?: string;
1441
    /**
1442
     * Which settings scopes this device is configured in
1443
     */
1444
    configuredIn?: ConfigurationScope[];
1445
    /**
1446
     * Current device state (inline on entry)
1447
     */
1448
    state?: DeviceState;
1449
    /**
1450
     * Previous state, updated by setDeviceState before each transition. Undefined when no
1451
     * state has been recorded yet — readers should treat that as 'unknown'.
1452
     */
1453
    lastState?: DeviceState;
1454
    /**
1455
     * Timestamp of last state update
1456
     */
1457
    stateLastUpdated?: number;
1458
}
1459

1460
/**
1461
 * Internal: discovered device from network
1462
 * Removed when device goes offline (ephemeral)
1463
 */
1464
interface DiscoveredDeviceEntry {
1465
    /**
1466
     * Current IP from SSDP/resolution
1467
     */
1468
    ip: string;
1469
    /**
1470
     * Serial number from device-info response
1471
     */
1472
    serialNumber?: string;
1473
    /**
1474
     * Current device state (inline on entry)
1475
     */
1476
    state?: DeviceState;
1477
    /**
1478
     * Previous state, updated by setDeviceState before each transition. Undefined when no
1479
     * state has been recorded yet — readers should treat that as 'unknown'.
1480
     */
1481
    lastState?: DeviceState;
1482
    /**
1483
     * Timestamp of last state update
1484
     */
1485
    stateLastUpdated?: number;
1486
}
1487

1488
/**
1489
 * Device state with timestamp, returned by getDeviceState
1490
 */
1491
interface DeviceStateEntry {
1492
    state: DeviceState;
1493
    lastUpdated: number;
1494
}
1495

1496
/**
1497
 * Full device details returned by public API
1498
 * Built on-demand by merging configured and discovered device data
1499
 */
1500
export interface RokuDevice {
1501
    /**
1502
     * Computed IP from resolution order: discovered > resolvedIp > host
1503
     */
1504
    ip: string;
1505
    /**
1506
     * Serial number from discovered or configured
1507
     */
1508
    serialNumber?: string;
1509
    /**
1510
     * Encoded device key: "s:{serial}" or "i:{ip}"
1511
     */
1512
    key: string;
1513
    /**
1514
     * Device state: online, offline, pending (currently checking), or unknown (never checked)
1515
     */
1516
    deviceState: DeviceState;
1517
    /**
1518
     * Previous device state: online, offline, pending (currently checking), or unknown (never checked)
1519
     */
1520
    lastDeviceState: DeviceState;
1521
    /**
1522
     * Cached device info from GlobalStateManager
1523
     */
1524
    deviceInfo: Record<string, any>;
1525
    /**
1526
     * True if device exists in discoveredDevices array
1527
     */
1528
    isDiscovered: boolean;
1529
    /**
1530
     * True if device exists in configuredDevices array
1531
     */
1532
    isConfigured: boolean;
1533
    /**
1534
     * Which settings scopes this device is configured in
1535
     */
1536
    configuredIn?: ConfigurationScope[];
1537
    /**
1538
     * User-provided name from config
1539
     */
1540
    configuredName?: string;
1541
    /**
1542
     * User-provided password from config
1543
     */
1544
    configuredPassword?: string;
1545
}
1546

1547
function throttleBounce<T extends (...args: any[]) => void>(
1548
    callback: T,
1549
    threshold: number
1550
): (...args: Parameters<T>) => void {
1551
    let timer: ReturnType<typeof setTimeout> | undefined;
1552
    let pending: Parameters<T> | undefined;
1553
    function onTimer() {
1554
        if (pending) {
254✔
1555
            callback(...pending);
56✔
1556
            pending = undefined;
56✔
1557
            timer = setTimeout(onTimer, threshold);
56✔
1558
        } else {
1559
            timer = undefined;
198✔
1560
        }
1561
    }
1562

1563
    return (...args: Parameters<T>) => {
254✔
1564
        if (!timer) {
328✔
1565
            callback(...args);
209✔
1566
            timer = setTimeout(onTimer, threshold);
209✔
1567
        } else {
1568
            pending = args;
119✔
1569
        }
1570
    };
1571
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc