Skip to content

Commit 5357085

Browse files
Merge branch 'latest' into copilot/fix-scoped-plugin-upgrade-issue
2 parents d089144 + 87a721d commit 5357085

3 files changed

Lines changed: 137 additions & 1 deletion

File tree

src/Platform.Matter.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'
4+
5+
/**
6+
* Unit tests for the stale HAP accessory cleanup in PluginUpdateMatterPlatform.
7+
*
8+
* When the platform runs in Matter mode, any cached HAP accessories from a previous
9+
* HAP-mode run (including legacy v2 accessories) are stale and must be unregistered
10+
* to prevent ghost/duplicate sensors in HomeKit.
11+
*/
12+
describe('PluginUpdateMatterPlatform stale HAP cleanup', () => {
13+
function buildMocks() {
14+
const unregisterCalls: Array<{ pluginName: string, platformName: string, accessories: any[] }> = []
15+
16+
const mockLog = {
17+
info: vi.fn(),
18+
warn: vi.fn(),
19+
error: vi.fn(),
20+
debug: vi.fn(),
21+
}
22+
23+
const mockApi = {
24+
matter: { registerPlatformAccessories: vi.fn(), uuid: { generate: vi.fn().mockReturnValue('matter-uuid') } },
25+
hap: { uuid: { generate: vi.fn().mockReturnValue('hap-uuid') } },
26+
on: vi.fn(),
27+
user: { storagePath: vi.fn().mockReturnValue('/tmp') },
28+
unregisterPlatformAccessories: vi.fn((...args: any[]) => {
29+
unregisterCalls.push({ pluginName: args[0], platformName: args[1], accessories: args[2] })
30+
}),
31+
}
32+
33+
const mockConfig = {
34+
name: 'PluginUpdate',
35+
sensorType: 'motion',
36+
failureSensorType: undefined as string | undefined,
37+
autoUpdateHomebridge: false,
38+
autoUpdateHomebridgeUI: false,
39+
autoUpdatePlugins: false,
40+
autoUpdateNode: false,
41+
}
42+
43+
return { mockLog, mockApi, mockConfig, unregisterCalls }
44+
}
45+
46+
/**
47+
* Simulate configureAccessory logic directly to avoid importing real Homebridge modules.
48+
*
49+
* This mirrors what PluginUpdateMatterPlatform.configureAccessory does:
50+
* it must call api.unregisterPlatformAccessories for each stale HAP accessory.
51+
*/
52+
function simulateConfigureAccessory(
53+
api: ReturnType<typeof buildMocks>['mockApi'],
54+
accessory: { UUID: string, displayName: string },
55+
) {
56+
api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
57+
}
58+
59+
it('unregisters a stale legacy v2 HAP accessory (PluginUpdate UUID)', () => {
60+
const { mockApi, unregisterCalls } = buildMocks()
61+
62+
const legacyAccessory = { UUID: mockApi.hap.uuid.generate('PluginUpdate'), displayName: 'Homebridge Plugin Update' }
63+
simulateConfigureAccessory(mockApi, legacyAccessory)
64+
65+
expect(unregisterCalls).toHaveLength(1)
66+
expect(unregisterCalls[0].pluginName).toBe(PLUGIN_NAME)
67+
expect(unregisterCalls[0].platformName).toBe(PLATFORM_NAME)
68+
expect(unregisterCalls[0].accessories).toContain(legacyAccessory)
69+
})
70+
71+
it('unregisters a stale v3 HAP update-sensor accessory (PluginUpdateCheck-UpdateSensor UUID)', () => {
72+
const { mockApi, unregisterCalls } = buildMocks()
73+
74+
const v3Accessory = { UUID: mockApi.hap.uuid.generate('PluginUpdateCheck-UpdateSensor'), displayName: 'PluginUpdate' }
75+
simulateConfigureAccessory(mockApi, v3Accessory)
76+
77+
expect(unregisterCalls).toHaveLength(1)
78+
expect(unregisterCalls[0].accessories).toContain(v3Accessory)
79+
})
80+
81+
it('unregisters multiple stale accessories when called for each', () => {
82+
const { mockApi, unregisterCalls } = buildMocks()
83+
84+
const accessory1 = { UUID: 'uuid-1', displayName: 'Accessory 1' }
85+
const accessory2 = { UUID: 'uuid-2', displayName: 'Accessory 2' }
86+
87+
simulateConfigureAccessory(mockApi, accessory1)
88+
simulateConfigureAccessory(mockApi, accessory2)
89+
90+
expect(unregisterCalls).toHaveLength(2)
91+
expect(unregisterCalls[0].accessories).toContain(accessory1)
92+
expect(unregisterCalls[1].accessories).toContain(accessory2)
93+
})
94+
})

src/Platform.Matter.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import type { API, Logging, PlatformConfig } from 'homebridge'
1+
import type { API, Logging, PlatformAccessory, PlatformConfig } from 'homebridge'
22

33
import { FailureSensor } from './failureSensor.js'
4+
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'
45
import { UpdateSensor } from './updateSensor.js'
56
import { isFailureSensorEnabled } from './utils.js'
67

78
export class PluginUpdateMatterPlatform {
9+
private readonly api: API
810
private updateSensor: UpdateSensor
911
private failureSensor: FailureSensor
1012

1113
constructor(log: Logging, config: PlatformConfig, api: API) {
14+
this.api = api
1215
this.failureSensor = new FailureSensor(log, api, config.failureSensorType || config.sensorType, 'matter')
1316
this.updateSensor = new UpdateSensor(log, config, api, {
1417
protocol: 'matter',
@@ -21,4 +24,13 @@ export class PluginUpdateMatterPlatform {
2124
this.failureSensor.configure({ displayName: failureDeviceName } as any)
2225
}
2326
}
27+
28+
/**
29+
* Called by Homebridge for each accessory found in the platform cache.
30+
* When running in Matter mode, all cached HAP accessories are stale — unregister
31+
* them immediately to prevent ghost accessories from appearing in HomeKit.
32+
*/
33+
configureAccessory(accessory: PlatformAccessory): void {
34+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
35+
}
2436
}

src/utils.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,34 @@ describe('createPlatformProxy', () => {
152152
expect(matterConstructed).toHaveLength(1)
153153
expect(hapConstructed).toHaveLength(0)
154154
})
155+
156+
it('should delegate configureAccessory to the Matter impl (stale HAP cleanup)', () => {
157+
const configuredAccessories: any[] = []
158+
159+
class MockHAPPlatform {
160+
constructor(_log: any, _config: any, _api: any) {}
161+
configureAccessory(accessory: any) {
162+
// Should not be called when Matter is active
163+
configuredAccessories.push({ platform: 'hap', accessory })
164+
}
165+
}
166+
167+
class MockMatterPlatform {
168+
constructor(_log: any, _config: any, _api: any) {}
169+
configureAccessory(accessory: any) {
170+
configuredAccessories.push({ platform: 'matter', accessory })
171+
}
172+
}
173+
174+
const ProxyCtor = createPlatformProxy(MockHAPPlatform, MockMatterPlatform)
175+
const api = { matter: {}, isMatterAvailable: () => true, isMatterEnabled: () => true }
176+
const proxy = new ProxyCtor('log', { enableMatter: true }, api)
177+
178+
const staleAccessory = { UUID: 'old-hap-uuid', displayName: 'Homebridge Plugin Update' }
179+
proxy.configureAccessory(staleAccessory)
180+
181+
expect(configuredAccessories).toHaveLength(1)
182+
expect(configuredAccessories[0].platform).toBe('matter')
183+
expect(configuredAccessories[0].accessory).toBe(staleAccessory)
184+
})
155185
})

0 commit comments

Comments
 (0)