Skip to content

Commit e3d214b

Browse files
authored
fix: Guard against ZHC throwing (#27093)
1 parent 8df5a48 commit e3d214b

File tree

6 files changed

+107
-12
lines changed

6 files changed

+107
-12
lines changed

lib/controller.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,22 +283,28 @@ export class Controller {
283283
async stop(restart = false): Promise<void> {
284284
this.sdNotify?.notifyStopping();
285285

286+
let code = 0;
287+
286288
for (const extension of this.extensions) {
287-
await this.stopExtension(extension);
289+
try {
290+
await extension.stop();
291+
} catch (error) {
292+
logger.error(`Failed to stop '${extension.constructor.name}' (${(error as Error).stack})`);
293+
code = 1;
294+
}
288295
}
289296

290297
this.eventBus.removeListeners(this);
291298

292299
// Wrap-up
293300
this.state.stop();
294301
await this.mqtt.disconnect();
295-
let code = 0;
296302

297303
try {
298304
await this.zigbee.stop();
299305
logger.info('Stopped Zigbee2MQTT');
300306
} catch (error) {
301-
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).message})`);
307+
logger.error(`Failed to stop Zigbee2MQTT (${(error as Error).stack})`);
302308
code = 1;
303309
}
304310

lib/extension/homeassistant.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1671,7 +1671,11 @@ export class HomeAssistant extends Extension {
16711671
}
16721672

16731673
if (entity.isDevice()) {
1674-
entity.definition?.meta?.overrideHaDiscoveryPayload?.(payload);
1674+
try {
1675+
entity.definition?.meta?.overrideHaDiscoveryPayload?.(payload);
1676+
} catch (error) {
1677+
logger.error(`Failed to override HA discovery payload (${(error as Error).stack})`);
1678+
}
16751679
}
16761680

16771681
const topic = this.getDiscoveryTopic(config, entity);

test/controller.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,26 @@ describe('Controller', () => {
391391
expect(mockExit).toHaveBeenCalledWith(1, false);
392392
});
393393

394+
it('does not throw when extension fails to stop on controller stop', async () => {
395+
vi.spyOn(Array.from(controller.extensions)[0], 'stop').mockRejectedValueOnce(new Error('failed'));
396+
await controller.start();
397+
await flushPromises();
398+
await controller.stop();
399+
await flushPromises();
400+
expect(mockMQTTEndAsync).toHaveBeenCalledTimes(1);
401+
expect(mockExit).toHaveBeenCalledTimes(1);
402+
expect(mockExit).toHaveBeenCalledWith(1, false);
403+
});
404+
405+
it('does not throw when extension stop throws', async () => {
406+
const ext = Array.from(controller.extensions)[0];
407+
vi.spyOn(ext, 'stop').mockRejectedValueOnce(new Error('failed'));
408+
await controller.start();
409+
await flushPromises();
410+
await controller.removeExtension(ext);
411+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to stop'));
412+
});
413+
394414
it('Handles reconnecting to MQTT', async () => {
395415
await controller.start();
396416
await flushPromises();

test/extensions/externalConverters.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {flushPromises} from '../mocks/utils';
55
import {devices, mockController as mockZHController, returnDevices} from '../mocks/zigbeeHerdsman';
66

77
import type Device from '../../lib/model/device';
8+
import type {Device as ZhDevice} from '../mocks/zigbeeHerdsman';
89

910
import fs from 'node:fs';
1011
import path from 'node:path';
@@ -50,7 +51,7 @@ describe('Extension: ExternalConverters', () => {
5051
return fs.readFileSync(path.join(__dirname, '..', 'assets', BASE_DIR, mtype, fileName), 'utf8');
5152
};
5253

53-
const getZ2MDevice = (zhDevice: unknown): Device => {
54+
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
5455
// @ts-expect-error private
5556
return controller.zigbee.resolveEntity(zhDevice)! as Device;
5657
};

test/extensions/homeassistant.test.ts

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import * as mockSleep from '../mocks/sleep';
55
import {flushPromises, getZhcBaseDefinitions} from '../mocks/utils';
66
import {devices, groups, events as mockZHEvents} from '../mocks/zigbeeHerdsman';
77

8+
import type {MockInstance} from 'vitest';
9+
10+
import type Device from '../../lib/model/device';
11+
import type Group from '../../lib/model/group';
12+
import type {Device as ZhDevice} from '../mocks/zigbeeHerdsman';
13+
814
import assert from 'node:assert';
915

1016
import stringify from 'json-stable-stringify-without-jsonify';
@@ -47,6 +53,11 @@ describe('Extension: HomeAssistant', () => {
4753
extension.discovered[id].triggers = new Set();
4854
};
4955

56+
const getZ2MEntity = (zhDeviceOrGroup: string | number | ZhDevice): Device | Group => {
57+
// @ts-expect-error private
58+
return controller.zigbee.resolveEntity(zhDeviceOrGroup)!;
59+
};
60+
5061
beforeAll(async () => {
5162
const {getZigbee2MQTTVersion} = await import('../../lib/util/utils.js');
5263
z2m_version = (await getZigbee2MQTTVersion()).version;
@@ -1032,6 +1043,61 @@ describe('Extension: HomeAssistant', () => {
10321043
});
10331044
});
10341045

1046+
it('does not throw when discovery payload override throws', async () => {
1047+
const bosch = getZ2MEntity(devices['RBSH-TRV0-ZB-EU']) as Device;
1048+
assert(typeof bosch.definition?.meta?.overrideHaDiscoveryPayload === 'function');
1049+
const overrideSpy = vi.spyOn(bosch.definition.meta, 'overrideHaDiscoveryPayload') as MockInstance;
1050+
1051+
overrideSpy.mockImplementation((payload) => {
1052+
if (payload.mode_command_topic?.endsWith('/system_mode')) {
1053+
throw new Error('Failed');
1054+
}
1055+
});
1056+
1057+
await resetExtension();
1058+
1059+
const payload = {
1060+
action_template:
1061+
"{% set values = {None:None,'idle':'idle','heat':'heating','cool':'cooling','fan_only':'fan'} %}{{ values[value_json.running_state] }}",
1062+
action_topic: 'zigbee2mqtt/bosch_radiator',
1063+
availability: [{topic: 'zigbee2mqtt/bridge/state', value_template: '{{ value_json.state }}'}],
1064+
current_temperature_template: '{{ value_json.local_temperature }}',
1065+
current_temperature_topic: 'zigbee2mqtt/bosch_radiator',
1066+
device: {
1067+
identifiers: ['zigbee2mqtt_0x18fc2600000d7ae2'],
1068+
manufacturer: 'Bosch',
1069+
model: 'Radiator thermostat II',
1070+
model_id: 'BTH-RA',
1071+
name: 'bosch_radiator',
1072+
sw_version: '3.05.09',
1073+
via_device: 'zigbee2mqtt_bridge_0x00124b00120144ae',
1074+
},
1075+
max_temp: '30',
1076+
min_temp: '5',
1077+
mode_command_topic: 'zigbee2mqtt/bosch_radiator/set/system_mode',
1078+
mode_state_template: '{{ value_json.system_mode }}',
1079+
mode_state_topic: 'zigbee2mqtt/bosch_radiator',
1080+
modes: ['heat'],
1081+
name: null,
1082+
object_id: 'bosch_radiator',
1083+
origin: origin,
1084+
temp_step: 0.5,
1085+
temperature_command_topic: 'zigbee2mqtt/bosch_radiator/set/occupied_heating_setpoint',
1086+
temperature_state_template: '{{ value_json.occupied_heating_setpoint }}',
1087+
temperature_state_topic: 'zigbee2mqtt/bosch_radiator',
1088+
temperature_unit: 'C',
1089+
unique_id: '0x18fc2600000d7ae2_climate_zigbee2mqtt',
1090+
};
1091+
1092+
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('homeassistant/climate/0x18fc2600000d7ae2/climate/config', stringify(payload), {
1093+
qos: 1,
1094+
retain: true,
1095+
});
1096+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to override HA discovery payload'));
1097+
1098+
overrideSpy.mockRestore();
1099+
});
1100+
10351101
it('Should discover devices with cover_position', async () => {
10361102
let payload;
10371103

@@ -1393,8 +1459,7 @@ describe('Extension: HomeAssistant', () => {
13931459
});
13941460

13951461
it('Should discover when options change', async () => {
1396-
// @ts-expect-error private
1397-
const device = controller.zigbee.resolveEntity(devices.bulb)!;
1462+
const device = getZ2MEntity(devices.bulb)! as Device;
13981463
assert('ieeeAddr' in device);
13991464
resetDiscoveryPayloads(device.ieeeAddr);
14001465
mockMQTTPublishAsync.mockClear();
@@ -2314,8 +2379,7 @@ describe('Extension: HomeAssistant', () => {
23142379

23152380
it('Should rediscover scenes when a scene is changed', async () => {
23162381
// Device/endpoint scenes.
2317-
// @ts-expect-error private
2318-
const device = controller.zigbee.resolveEntity(devices.bulb_color_2)!;
2382+
const device = getZ2MEntity(devices.bulb_color_2)! as Device;
23192383
assert('ieeeAddr' in device);
23202384
resetDiscoveryPayloads(device.ieeeAddr);
23212385

@@ -2353,8 +2417,7 @@ describe('Extension: HomeAssistant', () => {
23532417
});
23542418

23552419
// Group scenes.
2356-
// @ts-expect-error private
2357-
const group = controller.zigbee.resolveEntity('ha_discovery_group');
2420+
const group = getZ2MEntity('ha_discovery_group') as Group;
23582421
resetDiscoveryPayloads('9');
23592422

23602423
mockMQTTPublishAsync.mockClear();

test/extensions/onEvent.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {MockInstance} from 'vitest';
88
import type {OnEvent as DefinitionOnEvent} from 'zigbee-herdsman-converters/lib/types';
99

1010
import type Device from '../../lib/model/device';
11+
import type {Device as ZhDevice} from '../mocks/zigbeeHerdsman';
1112

1213
import * as zhc from 'zigbee-herdsman-converters';
1314

@@ -24,7 +25,7 @@ describe('Extension: OnEvent', () => {
2425
let onEventSpy: MockInstance<typeof zhc.onEvent>;
2526
let deviceOnEventSpy: MockInstance<DefinitionOnEvent>;
2627

27-
const getZ2MDevice = (zhDevice: unknown): Device => {
28+
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
2829
// @ts-expect-error private
2930
return controller.zigbee.resolveEntity(zhDevice)! as Device;
3031
};

0 commit comments

Comments
 (0)