Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

basic spam control / throttle #24122

Merged
merged 13 commits into from
Oct 2, 2024
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ Zigbee2MQTT is made up of three modules, each developed in its own Github projec
### Developing

Zigbee2MQTT uses TypeScript (partially for now). Therefore after making changes to files in the `lib/` directory you need to recompile Zigbee2MQTT. This can be done by executing `npm run build`. For faster development instead of running `npm run build` you can run `npm run build-watch` in another terminal session, this will recompile as you change files.
In first time before building you need to run `npm i --save-dev @types/node`
ivanfmartinez marked this conversation as resolved.
Show resolved Hide resolved
Before submitting changes run `npm run test-with-coverage` and `npm run pretty:check`
ivanfmartinez marked this conversation as resolved.
Show resolved Hide resolved

## Supported devices

Expand Down
34 changes: 33 additions & 1 deletion lib/extension/receive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,10 +125,42 @@ export default class Receive extends Extension {
const options: KeyValue = data.device.options;
zhc.postProcessConvertedFromZigbeeMessage(data.device.definition, payload, options);

if (settings.get().advanced.elapsed) {
const checkElapsedTime =
data.device.options.min_elapsed || (data.device.options.description && data.device.options.description.includes('SPAMMER'));

if (settings.get().advanced.elapsed || checkElapsedTime) {
const now = Date.now();
if (this.elapsed[data.device.ieeeAddr]) {
payload.elapsed = now - this.elapsed[data.device.ieeeAddr];

// very simple and dirty anti-spamming https://github.com/Koenkk/zigbee2mqtt/issues/17984
// as a proof of concept maybe Koenkk can find a better solution as the debounce does not help for my SPAMMER devices
// ambient sensor and water level that sometimes send mupliple messages on same second
// this will not help on zigbee network, but at least help on mqtt and homeassistant recorder and history
// this will not work for devices that have actions and specific events that are important
// this will only DISCARD messages that came to fast from device
// it solves the SPAMMING on sensor devices that does not change values too fast and messages can be ignored
// I dont know all the side effects of this code, but here is the ones that I found already
// - on web ui, the last-seen is only updated after a non ignored message
// - web ui are more responsive than before
// - my homeassistant does not have a lot of data from this devices that are not need
// - my homeassistant became more responsive
// - the CPU load are sensible lower
// using "SPAMMER" in description is an easy way to test without changing options on yaml
if (checkElapsedTime) {
let min_elapsed = 30000;
if (data.device.options.min_elapsed) {
min_elapsed = data.device.options.min_elapsed;
}

if (payload.elapsed < min_elapsed) {
logger.debug(
`Ignoring message from SPAMMER - ${data.device.ieeeAddr} - ${data.device.options.friendly_name} - elapsed=${payload.elapsed} - min_elapsed=${min_elapsed}`,
);
return;
}
}
// end of changes
}

this.elapsed[data.device.ieeeAddr] = now;
Expand Down
1 change: 1 addition & 0 deletions lib/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ declare global {
retrieve_state?: boolean;
debounce?: number;
debounce_ignore?: string[];
min_elapsed?: number;
filtered_attributes?: string[];
filtered_cache?: string[];
filtered_optimistic?: string[];
Expand Down
112 changes: 112 additions & 0 deletions test/receive.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,118 @@ describe('Receive', () => {
expect(JSON.parse(MQTT.publish.mock.calls[2][1])).toStrictEqual({temperature: 0.09, humidity: 0.01, pressure: 2});
});

it('Should ignore multiple messages from spamming devices', async () => {
const device = zigbeeHerdsman.devices.SPAMMER1;
const start = Date.now();
const min_elapsed_for_testing = 500;
settings.set(['device_options', 'min_elapsed'], min_elapsed_for_testing);
settings.set(['device_options', 'retain'], true);
const data1 = {measuredValue: 1};
const payload1 = {
data: data1,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload1);
const data2 = {measuredValue: 2};
const payload2 = {
data: data2,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload2);
const data3 = {measuredValue: 3};
const payload3 = {
data: data3,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload3);
await flushPromises();

expect(MQTT.publish).toHaveBeenCalledTimes(1);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/' + device.ieeeAddr);
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.01});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: true});

// Now we try after elapsed time to see if it publishes
const timeshift = min_elapsed_for_testing + 500;
jest.advanceTimersByTime(timeshift);
await zigbeeHerdsman.events.message(payload3);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/' + device.ieeeAddr);
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({elapsed: timeshift, temperature: 0.03});
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({qos: 0, retain: true});
});

it('Should ignore multiple messages from spamming devices defined by description', async () => {
const device = zigbeeHerdsman.devices.SPAMMER2;
const start = Date.now();
const min_elapsed_for_testing = 50000;
settings.set(['device_options', 'retain'], true);
settings.set(['devices', device.ieeeAddr, 'description'], 'this is a SPAMMER device');
const data1 = {measuredValue: 1};
const payload1 = {
data: data1,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload1);
const data2 = {measuredValue: 2};
const payload2 = {
data: data2,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload2);
const data3 = {measuredValue: 3};
const payload3 = {
data: data3,
cluster: 'msTemperatureMeasurement',
device,
endpoint: device.getEndpoint(1),
type: 'attributeReport',
linkquality: 10,
};
await zigbeeHerdsman.events.message(payload3);
await flushPromises();

expect(MQTT.publish).toHaveBeenCalledTimes(1);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(1);
expect(MQTT.publish.mock.calls[0][0]).toStrictEqual('zigbee2mqtt/' + device.ieeeAddr);
expect(JSON.parse(MQTT.publish.mock.calls[0][1])).toStrictEqual({temperature: 0.01});
expect(MQTT.publish.mock.calls[0][2]).toStrictEqual({qos: 0, retain: true});

// Now we try after elapsed time to see if it publishes
const timeshift = min_elapsed_for_testing + 500;
jest.advanceTimersByTime(timeshift);
await zigbeeHerdsman.events.message(payload3);
await flushPromises();
expect(MQTT.publish).toHaveBeenCalledTimes(2);
expect(MQTT.publish.mock.calls[1][0]).toStrictEqual('zigbee2mqtt/' + device.ieeeAddr);
expect(JSON.parse(MQTT.publish.mock.calls[1][1])).toStrictEqual({elapsed: timeshift, temperature: 0.03});
expect(MQTT.publish.mock.calls[1][2]).toStrictEqual({qos: 0, retain: true});
});

it('Shouldnt republish old state', async () => {
// https://github.com/Koenkk/zigbee2mqtt/issues/3572
const device = zigbeeHerdsman.devices.bulb;
Expand Down
3 changes: 3 additions & 0 deletions test/stub/zigbeeHerdsman.js
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,9 @@ const devices = {
'lumi.sensor_86sw2.es1',
),
WSDCGQ11LM: new Device('EndDevice', '0x0017880104e45522', 6539, 4151, [new Endpoint(1, [0], [])], true, 'Battery', 'lumi.weather'),
// These are not a real spammer devices, just copy of previous to test the spam filter
SPAMMER1: new Device('EndDevice', '0x0017880104e455fe', 6539, 4151, [new Endpoint(1, [0], [])], true, 'Battery', 'lumi.weather'),
SPAMMER2: new Device('EndDevice', '0x0017880104e455ff', 6539, 4151, [new Endpoint(1, [0], [])], true, 'Battery', 'lumi.weather'),
RTCGQ11LM: new Device('EndDevice', '0x0017880104e45523', 6540, 4151, [new Endpoint(1, [0], [])], true, 'Battery', 'lumi.sensor_motion.aq2'),
ZNCZ02LM: ZNCZ02LM,
E1743: new Device('Router', '0x0017880104e45540', 6540, 4476, [new Endpoint(1, [0], [])], true, 'Mains (single phase)', 'TRADFRI on/off switch'),
Expand Down
Loading