fix: Use Map for State (#27105)

This commit is contained in:
Nerivec
2025-04-14 21:51:01 +02:00
committed by GitHub
parent 6e346073e7
commit 36e2baf081
16 changed files with 89 additions and 85 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ type EventBusListener<K> = K extends keyof EventBusMap
: never;
export default class EventBus {
private callbacksByExtension: Map<string, {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]> = new Map();
private callbacksByExtension = new Map<string, {event: keyof EventBusMap; callback: EventBusListener<keyof EventBusMap>}[]>();
private emitter = new events.EventEmitter<EventBusMap>();
constructor() {
+5 -5
View File
@@ -20,15 +20,15 @@ const RETRIEVE_ON_RECONNECT: readonly {keys: string[]; condition?: (state: KeyVa
export default class Availability extends Extension {
/** Mapped by IEEE address */
private readonly timers: Map<string, NodeJS.Timeout> = new Map();
private readonly timers = new Map<string, NodeJS.Timeout>();
/** Mapped by IEEE address or Group ID */
private readonly lastPublishedAvailabilities: Map<string | number, boolean> = new Map();
private readonly lastPublishedAvailabilities = new Map<string | number, boolean>();
/** Mapped by IEEE address */
private readonly pingBackoffs: Map<string, number> = new Map();
private readonly pingBackoffs = new Map<string, number>();
/** IEEE addresses, waiting for last seen changes to take them out of "availability sleep" */
private readonly backoffPausedDevices: Set<string> = new Set();
private readonly backoffPausedDevices = new Set<string>();
/** Mapped by IEEE address */
private readonly retrieveStateDebouncers: Map<string, () => void> = new Map();
private readonly retrieveStateDebouncers = new Map<string, () => void>();
private pingQueue: Device[] = [];
private pingQueueExecuting = false;
private stopped = false;
+1 -1
View File
@@ -567,7 +567,7 @@ export default class Bind extends Extension {
);
if (polls.length) {
const toPoll: Set<zh.Endpoint> = new Set();
const toPoll = new Set<zh.Endpoint>();
// Add bound devices
for (const endpoint of data.device.zh.endpoints) {
+1 -1
View File
@@ -106,7 +106,7 @@ export default class Groups extends Extension {
// Invalidate the last optimistic group state when group state is changed directly.
delete this.lastOptimisticState[entity.ID];
const groupsToPublish: Set<Group> = new Set();
const groupsToPublish = new Set<Group>();
for (const member of entity.zh.members) {
const device = this.zigbee.resolveEntity(member.getDevice()) as Device;
+1 -1
View File
@@ -1472,7 +1472,7 @@ export class HomeAssistant extends Extension {
const discovered = this.getDiscovered(entity);
discovered.discovered = true;
const lastDiscoveredTopics = Object.keys(discovered.messages);
const newDiscoveredTopics: Set<string> = new Set();
const newDiscoveredTopics = new Set<string>();
for (const config of this.getConfigs(entity)) {
const payload = {...config.discovery_payload};
+3 -3
View File
@@ -185,9 +185,9 @@ export default class NetworkMap extends Extension {
async networkScan(includeRoutes: boolean): Promise<Zigbee2MQTTNetworkMap> {
logger.info(`Starting network scan (includeRoutes '${includeRoutes}')`);
const lqis: Map<Device, zh.LQI> = new Map();
const routingTables: Map<Device, zh.RoutingTable> = new Map();
const failed: Map<Device, string[]> = new Map();
const lqis = new Map<Device, zh.LQI>();
const routingTables = new Map<Device, zh.RoutingTable>();
const failed = new Map<Device, string[]>();
const requestWithRetry = async <T>(request: () => Promise<T>): Promise<T> => {
try {
const result = await request();
+1 -1
View File
@@ -14,7 +14,7 @@ import utils from './util/utils';
const NS = 'z2m:mqtt';
export default class MQTT {
private publishedTopics: Set<string> = new Set();
private publishedTopics = new Set<string>();
private connectionTimer?: NodeJS.Timeout;
private client!: MqttClient;
private eventBus: EventBus;
+33 -21
View File
@@ -1,4 +1,4 @@
import fs from 'node:fs';
import {existsSync, readFileSync, writeFileSync} from 'node:fs';
import objectAssignDeep from 'object-assign-deep';
@@ -7,9 +7,8 @@ import logger from './util/logger';
import * as settings from './util/settings';
import utils from './util/utils';
const saveInterval = 1000 * 60 * 5; // 5 minutes
const dontCacheProperties = [
const SAVE_INTERVAL = 1000 * 60 * 5; // 5 minutes
const CACHE_IGNORE_PROPERTIES = [
'action',
'action_.*',
'button',
@@ -32,8 +31,8 @@ const dontCacheProperties = [
];
class State {
private state: {[s: string | number]: KeyValue} = {};
private file = data.joinPath('state.json');
private readonly state = new Map<string | number, KeyValue>();
private readonly file = data.joinPath('state.json');
private timer?: NodeJS.Timeout;
constructor(
@@ -48,15 +47,15 @@ class State {
this.load();
// Save the state on every interval
this.timer = setInterval(() => this.save(), saveInterval);
this.timer = setInterval(() => this.save(), SAVE_INTERVAL);
}
stop(): void {
// Remove any invalid states (ie when the device has left the network) when the system is stopped
for (const key in this.state) {
if (typeof key === 'string' && !this.zigbee.resolveEntity(key)) {
for (const [key] of this.state) {
if (typeof key === 'string' && key.startsWith('0x') && !this.zigbee.resolveEntity(key)) {
// string key = ieeeAddr
delete this.state[key];
this.state.delete(key);
}
}
@@ -64,10 +63,21 @@ class State {
this.save();
}
clear(): void {
this.state.clear();
}
private load(): void {
if (fs.existsSync(this.file)) {
this.state.clear();
if (existsSync(this.file)) {
try {
this.state = JSON.parse(fs.readFileSync(this.file, 'utf8'));
const stateObj = JSON.parse(readFileSync(this.file, 'utf8')) as KeyValue;
for (const key in stateObj) {
this.state.set(key.startsWith('0x') ? key : Number.parseInt(key, 10), stateObj[key]);
}
logger.debug(`Loaded state from file ${this.file}`);
} catch (error) {
logger.debug(`Failed to load state from file ${this.file} (corrupt file?) (${(error as Error).message})`);
@@ -80,9 +90,11 @@ class State {
private save(): void {
if (settings.get().advanced.cache_state_persistent) {
logger.debug(`Saving state to file ${this.file}`);
const json = JSON.stringify(this.state, null, 4);
const json = JSON.stringify(Object.fromEntries(this.state), null, 4);
try {
fs.writeFileSync(this.file, json, 'utf8');
writeFileSync(this.file, json, 'utf8');
} catch (error) {
logger.error(`Failed to write state to '${this.file}' (${error})`);
}
@@ -92,28 +104,28 @@ class State {
}
exists(entity: Device | Group): boolean {
return this.state[entity.ID] !== undefined;
return this.state.has(entity.ID);
}
get(entity: Group | Device): KeyValue {
return this.state[entity.ID] || {};
return this.state.get(entity.ID) || {};
}
set(entity: Group | Device, update: KeyValue, reason?: string): KeyValue {
const fromState = this.state[entity.ID] || {};
const fromState = this.state.get(entity.ID) || {};
const toState = objectAssignDeep({}, fromState, update);
const newCache = {...toState};
const entityDontCacheProperties = entity.options.filtered_cache || [];
utils.filterProperties(dontCacheProperties.concat(entityDontCacheProperties), newCache);
utils.filterProperties(CACHE_IGNORE_PROPERTIES.concat(entityDontCacheProperties), newCache);
this.state[entity.ID] = newCache;
this.state.set(entity.ID, newCache);
this.eventBus.emitStateChange({entity, from: fromState, to: toState, reason, update});
return toState;
}
remove(ID: string | number): void {
delete this.state[ID];
remove(ID: string | number): boolean {
return this.state.delete(ID);
}
}
+1 -1
View File
@@ -510,7 +510,7 @@ export function migrateIfNecessary(): void {
while (currentSettings.version !== finalVersion) {
let migrationNotesFileName: string | undefined;
// don't duplicate outputs
const migrationNotes: Set<string> = new Set();
const migrationNotes = new Set<string>();
const transfers: SettingsTransfer[] = [];
const changes: SettingsChange[] = [];
const additions: SettingsAdd[] = [];
+2 -2
View File
@@ -21,8 +21,8 @@ export default class Zigbee {
// @ts-expect-error initialized in start
private herdsman: Controller;
private eventBus: EventBus;
private groupLookup: Map<number /* group ID */, Group> = new Map();
private deviceLookup: Map<string /* IEEE address */, Device> = new Map();
private groupLookup = new Map<number /* group ID */, Group>();
private deviceLookup = new Map<string /* IEEE address */, Device>();
constructor(eventBus: EventBus) {
this.eventBus = eventBus;
+33 -41
View File
@@ -13,6 +13,9 @@ import {devices, mockController as mockZHController, events as mockZHEvents, ret
import type {Mock, MockInstance} from 'vitest';
import type Device from '../lib/model/device';
import type {Device as ZhDevice} from './mocks/zigbeeHerdsman';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -54,6 +57,11 @@ describe('Controller', () => {
let controller: Controller;
let mockExit: Mock;
const getZ2MDevice = (zhDevice: string | number | ZhDevice): Device => {
// @ts-expect-error private
return controller.zigbee.resolveEntity(zhDevice)! as Device;
};
beforeAll(async () => {
vi.useFakeTimers();
});
@@ -64,7 +72,7 @@ describe('Controller', () => {
data.writeDefaultConfiguration();
settings.reRead();
controller = new Controller(vi.fn(), mockExit);
mocksClear.forEach((m) => m.mockClear());
for (const mock of mocksClear) mock.mockClear();
settings.reRead();
data.writeDefaultState();
});
@@ -264,8 +272,7 @@ describe('Controller', () => {
mockLogger.error.mockClear();
// @ts-expect-error private
controller.mqtt.client.reconnecting = true;
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb');
const device = getZ2MDevice('bulb');
await controller.publishEntityState(device, {
state: 'ON',
brightness: 50,
@@ -302,7 +309,7 @@ describe('Controller', () => {
await controller.start();
await flushPromises();
// @ts-expect-error private
expect(controller.state.state).toStrictEqual({});
expect(controller.state.state).toStrictEqual(new Map());
});
it('Should remove device not on passlist on startup', async () => {
@@ -737,8 +744,7 @@ describe('Controller', () => {
await controller.start();
settings.set(['advanced', 'output'], 'attribute');
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
const device = getZ2MDevice('bulb');
await controller.publishEntityState(device, {
dummy: {1: 'yes', 2: 'no'},
color: {r: 100, g: 50, b: 10},
@@ -763,8 +769,7 @@ describe('Controller', () => {
await controller.start();
settings.set(['advanced', 'output'], 'attribute_and_json');
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
const device = getZ2MDevice('bulb');
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(5);
@@ -784,8 +789,7 @@ describe('Controller', () => {
settings.set(['advanced', 'output'], 'attribute_and_json');
settings.set(['devices', devices.bulb.ieeeAddr, 'filtered_attributes'], ['color_temp', 'linkquality']);
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
const device = getZ2MDevice('bulb');
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
@@ -799,8 +803,7 @@ describe('Controller', () => {
settings.set(['advanced', 'output'], 'attribute_and_json');
settings.set(['device_options', 'filtered_attributes'], ['color_temp', 'linkquality']);
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
const device = getZ2MDevice('bulb');
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 99});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(3);
@@ -815,16 +818,15 @@ describe('Controller', () => {
settings.set(['devices', devices.bulb.ieeeAddr, 'filtered_cache'], ['linkquality']);
mockMQTTPublishAsync.mockClear();
const device = getZ2MDevice('bulb');
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
// @ts-expect-error private
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: 'ON'});
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: 'ON'});
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 87});
await flushPromises();
// @ts-expect-error private
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 200, color_temp: 370, state: 'ON'});
expect(controller.state.get(device)).toStrictEqual({brightness: 200, color_temp: 370, state: 'ON'});
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(5);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '200', {qos: 0, retain: true});
@@ -842,16 +844,15 @@ describe('Controller', () => {
settings.set(['device_options', 'filtered_cache'], ['linkquality']);
mockMQTTPublishAsync.mockClear();
const device = getZ2MDevice('bulb');
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
// @ts-expect-error private
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: 'ON'});
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: 'ON'});
await controller.publishEntityState(device, {state: 'ON', brightness: 200, color_temp: 370, linkquality: 87});
await flushPromises();
// @ts-expect-error private
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 200, color_temp: 370, state: 'ON'});
expect(controller.state.get(device)).toStrictEqual({brightness: 200, color_temp: 370, state: 'ON'});
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(5);
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb/state', 'ON', {qos: 0, retain: true});
expect(mockMQTTPublishAsync).toHaveBeenCalledWith('zigbee2mqtt/bulb/brightness', '200', {qos: 0, retain: true});
@@ -867,8 +868,7 @@ describe('Controller', () => {
await controller.start();
settings.set(['mqtt', 'include_device_information'], true);
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
let device = controller.zigbee.resolveEntity('bulb')!;
let device = getZ2MDevice('bulb');
await controller.publishEntityState(device, {state: 'ON'});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
@@ -892,8 +892,7 @@ describe('Controller', () => {
);
// Unsupported device should have model "unknown"
// @ts-expect-error private
device = controller.zigbee.resolveEntity('unsupported2')!;
device = getZ2MDevice('unsupported2');
await controller.publishEntityState(device, {state: 'ON'});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
@@ -918,8 +917,7 @@ describe('Controller', () => {
await controller.start();
settings.set(['devices', devices.bulb.ieeeAddr, 'retain'], false);
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
const device = getZ2MDevice('bulb');
await controller.publishEntityState(device, {state: 'ON'});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
@@ -933,8 +931,7 @@ describe('Controller', () => {
await controller.start();
settings.set(['devices', devices.bulb.ieeeAddr, 'retain'], true);
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
const device = getZ2MDevice('bulb');
await controller.publishEntityState(device, {state: 'ON'});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
@@ -950,8 +947,7 @@ describe('Controller', () => {
settings.set(['devices', devices.bulb.ieeeAddr, 'retain'], true);
settings.set(['devices', devices.bulb.ieeeAddr, 'retention'], 37);
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
const device = getZ2MDevice('bulb');
await controller.publishEntityState(device, {state: 'ON'});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledWith(
@@ -965,8 +961,7 @@ describe('Controller', () => {
data.writeEmptyState();
await controller.start();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
const device = getZ2MDevice('bulb');
await controller.publishEntityState(device, {});
await flushPromises();
expect(mockMQTTPublishAsync).toHaveBeenCalledTimes(0);
@@ -977,8 +972,7 @@ describe('Controller', () => {
data.removeState();
await controller.start();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
const device = getZ2MDevice('bulb');
await controller.publishEntityState(device, {state: 'ON'});
await controller.publishEntityState(device, {brightness: 200});
await flushPromises();
@@ -1005,8 +999,7 @@ describe('Controller', () => {
data.writeEmptyState();
await controller.start();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
const device = getZ2MDevice('bulb');
await controller.publishEntityState(device, {state: 'ON'});
await controller.publishEntityState(device, {brightness: 200});
await flushPromises();
@@ -1020,7 +1013,7 @@ describe('Controller', () => {
await controller.start();
await flushPromises();
// @ts-expect-error private
expect(controller.state.state).toStrictEqual({});
expect(controller.state.state).toStrictEqual(new Map());
});
it('Start controller with force_disable_retain', async () => {
@@ -1120,14 +1113,13 @@ describe('Controller', () => {
it('Should remove state of removed device when stopped', async () => {
await controller.start();
const device = getZ2MDevice('bulb');
// @ts-expect-error private
const device = controller.zigbee.resolveEntity('bulb')!;
// @ts-expect-error private
expect(controller.state.state[device.ieeeAddr]).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: 'ON'});
expect(controller.state.get(device)).toStrictEqual({brightness: 50, color_temp: 370, linkquality: 99, state: 'ON'});
device.zh.isDeleted = true;
await controller.stop();
// @ts-expect-error private
expect(controller.state.state[device.ieeeAddr]).toStrictEqual(undefined);
expect(controller.state.state.get(device.ieeeAddr)).toStrictEqual(undefined);
});
it('EventBus should handle errors', async () => {
+2 -2
View File
@@ -78,7 +78,7 @@ describe('Extension: Bridge', () => {
// @ts-expect-error private
extension.restartRequired = false;
// @ts-expect-error private
controller.state.state = {[devices.bulb.ieeeAddr]: {brightness: 50}};
controller.state.state = new Map([[devices.bulb.ieeeAddr, {brightness: 50}]]);
fs.rmSync(deviceIconsDir, {force: true, recursive: true});
});
@@ -2809,7 +2809,7 @@ describe('Extension: Bridge', () => {
mockMQTTEvents.message('zigbee2mqtt/bridge/request/device/remove', 'bulb');
await flushPromises();
// @ts-expect-error private
expect(controller.state[device.ieeeAddr]).toBeUndefined();
expect(controller.state.state.get(device.ieeeAddr)).toBeUndefined();
expect(device.removeFromNetwork).toHaveBeenCalledTimes(1);
expect(device.removeFromDatabase).not.toHaveBeenCalled();
expect(settings.getDevice('bulb')).toBeUndefined();
+2 -2
View File
@@ -46,7 +46,7 @@ describe('Extension: Groups', () => {
groups.gledopto_group.command.mockClear();
zhcGlobalStore.clear();
// @ts-expect-error private
controller.state.state = {};
controller.state.clear();
});
it('Should publish group state change when a device in it changes state', async () => {
@@ -461,7 +461,7 @@ describe('Extension: Groups', () => {
await flushPromises();
mockMQTTPublishAsync.mockClear();
// @ts-expect-error private
controller.state.state = {};
controller.state.clear();
await mockMQTTEvents.message('zigbee2mqtt/bulb_color/set', stringify({state: 'OFF'}));
await flushPromises();
+1 -1
View File
@@ -70,7 +70,7 @@ describe('Extension: OTAUpdate', () => {
updateSpy.mockClear();
isUpdateAvailableSpy.mockClear();
// @ts-expect-error private
controller.state.state = {};
controller.state.clear();
});
afterEach(() => {
+1 -1
View File
@@ -43,7 +43,7 @@ describe('Extension: Publish', () => {
beforeEach(async () => {
data.writeDefaultConfiguration();
// @ts-expect-error private
controller.state.state = {};
controller.state.clear();
settings.reRead();
loadTopicGetSetRegex();
mocksClear.forEach((m) => m.mockClear());
+1 -1
View File
@@ -25,7 +25,7 @@ describe('Extension: Receive', () => {
beforeEach(async () => {
// @ts-expect-error private
controller.state.state = {};
controller.state.clear();
data.writeDefaultConfiguration();
settings.reRead();
mocksClear.forEach((m) => m.mockClear());