1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
|
import { MqttClient } from 'mqtt'
import { Airmx, Topic } from './airmx.js'
import { EagleStatus } from './eagle.js'
describe('topic', () => {
test('parse parses topic from string', () => {
const topic = Topic.parse('airmx/01/0/1/1/1/12345')
expect(topic.deviceId).toBe(12345)
expect(topic.unknown1).toBe(false)
expect(topic.unknown2).toBe(true)
expect(topic.unknown3).toBe(true)
expect(topic.unknown4).toBe(true)
})
test('the topic format is expected to have 7 parts', () => {
expect(() => Topic.parse('foo')).toThrow(
'The topic format is expected to be airmx/+/+/+/+/+/+.',
)
})
test('the 1st part is expected to be airmx', () => {
expect(() => Topic.parse('foo/01/0/1/1/1/12345')).toThrow(
'The 1st part of the topic must be "airmx".',
)
})
test('the 2nd part is expected to be "01"', () => {
expect(() => Topic.parse('airmx/00/0/1/1/1/12345')).toThrow(
'The 2nd part of the topic must be "01".',
)
})
test('the 3rd part is expected to be either "1" or "0"', () => {
expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic)
expect(Topic.parse('airmx/01/1/1/1/1/12345')).toBeInstanceOf(Topic)
expect(() => Topic.parse('airmx/01/2/1/1/1/12345')).toThrow(
'The 3rd part of the topic must be either "1" or "0".',
)
})
test('the 4th part is expected to be either "1" or "0"', () => {
expect(Topic.parse('airmx/01/0/0/1/1/12345')).toBeInstanceOf(Topic)
expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic)
expect(() => Topic.parse('airmx/01/0/2/1/1/12345')).toThrow(
'The 4th part of the topic must be either "1" or "0".',
)
})
test('the 5th part is expected to be either "1" or "0"', () => {
expect(Topic.parse('airmx/01/0/1/0/1/12345')).toBeInstanceOf(Topic)
expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic)
expect(() => Topic.parse('airmx/01/0/1/2/1/12345')).toThrow(
'The 5th part of the topic must be either "1" or "0".',
)
})
test('the 6th part is expected to be either "1" or "0"', () => {
expect(Topic.parse('airmx/01/0/1/1/0/12345')).toBeInstanceOf(Topic)
expect(Topic.parse('airmx/01/0/1/1/1/12345')).toBeInstanceOf(Topic)
expect(() => Topic.parse('airmx/01/0/1/1/2/12345')).toThrow(
'The 6th part of the topic must be either "1" or "0".',
)
})
test('the 7th part is expected to be the device id', () => {
expect(() => Topic.parse('airmx/01/0/1/1/1/')).toThrow(
'The 7th part of the topic must be a device ID.',
)
expect(() => Topic.parse('airmx/01/0/1/1/1/foo')).toThrow(
'The 7th part of the topic must be a device ID.',
)
})
})
describe('airmx', () => {
let mockMqttClient: jest.Mocked<MqttClient>
beforeEach(() => {
mockMqttClient = {
on: jest.fn(),
subscribe: jest.fn(),
} as unknown as jest.Mocked<MqttClient>
})
it('should subscribe to the topic when the client connects', () => {
new Airmx({ mqtt: mockMqttClient, devices: [] })
const connectHandler = mockMqttClient.on.mock.calls.find(
([event]) => event === 'connect',
)?.[1] as (() => void) | undefined
connectHandler?.()
expect(mockMqttClient.subscribe).toHaveBeenCalledWith('airmx/01/+/+/1/1/+')
})
it('should record the latest status for eagles', () => {
const testDevice = { id: 1, key: 'f0eb21fe346c88e1d1ac73546022cd5d' }
const message =
'{"cmdId": 210,"name":"eagleStatus","time":1752675701,"from":2,"data":{"version":"10.00.17","power":1,"heatStatus":0,"mode":0,"cadr":47,"prm":1320,"g4Percent": 100,"hepaPercent":100,"carbonId":"031","g4Id":"041","hepaId":"021","carbonPercent":17,"diffPressure1":99999,"diffPressure2":99999,"t0":35,"status":0,"denoise":1},"sig":"b8796682da77e8c929dddf7e6461afec"}'
const airmx = new Airmx({ mqtt: mockMqttClient, devices: [testDevice] })
const messageHandler = mockMqttClient.on.mock.calls.find(
([event]) => event === 'message',
)?.[1] as ((topic: string, message: Buffer) => void) | undefined
expect(messageHandler).toBeDefined()
messageHandler!('airmx/01/0/1/1/1/1', Buffer.from(message))
const status = airmx.getEagleStatus(1)
expect(status).toBeInstanceOf(EagleStatus)
})
describe('message validation', () => {
const testDevice = { id: 1, key: 'f0eb21fe346c88e1d1ac73546022cd5d' }
let messageHandler: ((topic: string, message: Buffer) => void) | undefined
beforeEach(() => {
new Airmx({ mqtt: mockMqttClient, devices: [testDevice] })
messageHandler = mockMqttClient.on.mock.calls.find(
([event]) => event === 'message',
)?.[1] as ((topic: string, message: Buffer) => void) | undefined
expect(messageHandler).toBeDefined()
})
const validMessage =
'{"cmdId": 210,"name":"eagleStatus","time":1752675701,"from":2,"data":{"version":"10.00.17","power":1,"heatStatus":0,"mode":0,"cadr":47,"prm":1320,"g4Percent": 100,"hepaPercent":100,"carbonId":"031","g4Id":"041","hepaId":"021","carbonPercent":17,"diffPressure1":99999,"diffPressure2":99999,"t0":35,"status":0,"denoise":1},"sig":"b8796682da77e8c929dddf7e6461afec"}'
const invalidMessage =
'{"cmdId": 210,"name":"eagleStatus","time":1752675701,"from":2,"data":{"version":"10.00.17","power":1,"heatStatus":0,"mode":0,"cadr":47,"prm":1320,"g4Percent": 100,"hepaPercent":100,"carbonId":"031","g4Id":"041","hepaId":"021","carbonPercent":17,"diffPressure1":99999,"diffPressure2":99999,"t0":35,"status":0,"denoise":1},"sig":"invalid"}'
it('should validate message signatures when receiving messages', () => {
expect(() => {
messageHandler?.('airmx/01/0/1/1/1/1', Buffer.from(validMessage))
}).not.toThrow()
})
it('should throw errors when message signatures are invalid', () => {
expect(() => {
messageHandler?.('airmx/01/0/1/1/1/1', Buffer.from(invalidMessage))
}).toThrow('Failed to validate the message.')
})
it('should throw errors when device does not exist', () => {
expect(() => {
messageHandler?.('airmx/01/0/1/1/1/99999', Buffer.from(validMessage))
}).toThrow('Could not find the device with ID 99999.')
})
})
})
|