Skip to content

Commit 5d40b3a

Browse files
committed
Add HomieObserver and associated tests
1 parent f57bfb6 commit 5d40b3a

14 files changed

+1089
-335
lines changed

dist/homie-lit.js

+25-328
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/homie-lit.js.LICENSE.txt

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* @license
3+
* Copyright 2017 Google LLC
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
*/
6+
7+
/**
8+
* @license
9+
* Copyright 2019 Google LLC
10+
* SPDX-License-Identifier: BSD-3-Clause
11+
*/
12+
13+
/**
14+
* @license
15+
* Copyright 2021 Google LLC
16+
* SPDX-License-Identifier: BSD-3-Clause
17+
*/

dist/src/HomieObserver.d.ts

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/// <reference types="node" />
2+
import { Observable } from 'rxjs';
3+
import mqtt from 'mqtt';
4+
declare module 'mqtt' {
5+
interface Client {
6+
on(event: 'connect', callback: () => void): this;
7+
on(event: 'message', callback: (topic: string, message: Buffer) => void): this;
8+
subscribe(topic: string | string[], options?: mqtt.IClientSubscribeOptions, callback?: mqtt.ClientSubscribeCallback): this;
9+
end(force?: boolean, options?: object, callback?: () => void): this;
10+
}
11+
interface IClientOptions {
12+
}
13+
function connect(brokerUrl: string, options?: IClientOptions): Client;
14+
}
15+
interface HomieProperty {
16+
id: string;
17+
value: any;
18+
}
19+
interface HomieNode {
20+
id: string;
21+
properties: {
22+
[key: string]: HomieProperty;
23+
};
24+
}
25+
interface HomieDevice {
26+
id: string;
27+
nodes: {
28+
[key: string]: HomieNode;
29+
};
30+
}
31+
declare enum HomieEventType {
32+
Device = "device",
33+
Node = "node",
34+
Property = "property"
35+
}
36+
interface HomieDeviceEvent {
37+
type: HomieEventType.Device;
38+
device: HomieDevice;
39+
}
40+
interface HomieNodeEvent {
41+
type: HomieEventType.Node;
42+
device: HomieDevice;
43+
node: HomieNode;
44+
}
45+
interface HomiePropertyEvent {
46+
type: HomieEventType.Property;
47+
device: HomieDevice;
48+
node: HomieNode;
49+
property: HomieProperty;
50+
}
51+
type HomieEvent = HomieDeviceEvent | HomieNodeEvent | HomiePropertyEvent;
52+
interface MqttMessageHandler {
53+
handleMessage(topic: string, message: Buffer): void;
54+
}
55+
declare class MqttClient implements MqttMessageHandler {
56+
private client;
57+
private homiePrefix;
58+
private messageCallback;
59+
constructor(brokerUrl: string, options: {
60+
homiePrefix?: string | undefined;
61+
} | undefined, messageCallback: (event: HomieEvent) => void);
62+
subscribe(pattern: string): void;
63+
private getSubscriptionTopic;
64+
handleMessage(topic: string, message: Buffer): void;
65+
private handleDeviceState;
66+
private handleNodeState;
67+
private handlePropertyState;
68+
disconnect(): void;
69+
}
70+
declare class HomieObserver {
71+
private messageHandler;
72+
private devices;
73+
private onCreate;
74+
private onUpdate;
75+
private onDelete;
76+
constructor(messageHandler: MqttMessageHandler);
77+
get created$(): Observable<HomieEvent>;
78+
get updated$(): Observable<HomieEvent>;
79+
get deleted$(): Observable<HomieEvent>;
80+
processEvent(event: HomieEvent): void;
81+
private processDeviceEvent;
82+
private processNodeEvent;
83+
private processPropertyEvent;
84+
}
85+
declare function createMqttHomieObserver(brokerUrl: string, options?: {
86+
homiePrefix?: string;
87+
}): HomieObserver;
88+
export { HomieObserver, MqttClient, MqttMessageHandler, createMqttHomieObserver, HomieEventType, HomieEvent };

dist/test/HomieObserver.test.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { HomieObserver, HomieEventType, createMqttHomieObserver } from '../src/HomieObserver';
2+
import * as mqtt from 'mqtt';
3+
import { Subscription } from 'rxjs';
4+
5+
describe('HomieObserver Integration Test', () => {
6+
let observer: HomieObserver;
7+
let client: mqtt.Client;
8+
let homiePrefix: string;
9+
let subscriptions: Subscription[] = [];
10+
11+
beforeAll((done) => {
12+
homiePrefix = `test-homie-${Math.random().toString(36).substring(7)}`;
13+
observer = createMqttHomieObserver('mqtt://localhost', { homiePrefix });
14+
client = mqtt.connect('mqtt://localhost');
15+
16+
client.on('connect', () => {
17+
done();
18+
});
19+
});
20+
21+
afterAll((done) => {
22+
const cleanup = () => {
23+
subscriptions.forEach(sub => sub.unsubscribe());
24+
subscriptions = [];
25+
26+
if (client.connected) {
27+
client.end(false, {}, () => {
28+
if (observer && (observer as any).client && typeof (observer as any).client.end === 'function') {
29+
(observer as any).client.end(false, {}, done);
30+
} else if (observer && (observer as any).disconnect === 'function') {
31+
(observer as any).disconnect();
32+
} else {
33+
done();
34+
}
35+
});
36+
} else {
37+
done();
38+
}
39+
};
40+
41+
cleanup();
42+
});
43+
44+
test('should handle device creation, node addition, and property update', (done) => {
45+
const deviceId = 'test-device';
46+
const nodeId = 'test-node';
47+
const propertyId = 'test-property';
48+
let step = 0;
49+
50+
const createdHandler = jest.fn();
51+
const updatedHandler = jest.fn();
52+
53+
const cleanupAndDone = (error?: Error) => {
54+
subscriptions.forEach(sub => sub.unsubscribe());
55+
subscriptions = [];
56+
client.removeAllListeners('message');
57+
if (error) {
58+
done(error);
59+
} else {
60+
done();
61+
}
62+
};
63+
64+
subscriptions.push(observer.created$.subscribe(createdHandler));
65+
subscriptions.push(observer.updated$.subscribe(updatedHandler));
66+
67+
subscriptions.push(observer.created$.subscribe((event) => {
68+
try {
69+
if (step === 0 && event.type === HomieEventType.Device) {
70+
expect(event.device.id).toBe(deviceId);
71+
step++;
72+
client.publish(`${homiePrefix}/${deviceId}/${nodeId}/$name`, 'Test Node');
73+
} else if (step === 1 && event.type === HomieEventType.Node) {
74+
expect(event.node.id).toBe(nodeId);
75+
step++;
76+
client.publish(`${homiePrefix}/${deviceId}/${nodeId}/${propertyId}`, 'initial value');
77+
} else if (step === 2 && event.type === HomieEventType.Property) {
78+
expect(event.property.id).toBe(propertyId);
79+
expect(event.property.value).toBe('initial value');
80+
step++;
81+
client.publish(`${homiePrefix}/${deviceId}/${nodeId}/${propertyId}`, 'updated value');
82+
}
83+
} catch (error) {
84+
cleanupAndDone(error instanceof Error ? error : new Error('An unknown error occurred'));
85+
}
86+
}));
87+
88+
subscriptions.push(observer.updated$.subscribe((event) => {
89+
try {
90+
if (step === 3 && event.type === HomieEventType.Property) {
91+
expect(event.property.id).toBe(propertyId);
92+
expect(event.property.value).toBe('updated value');
93+
94+
expect(createdHandler).toHaveBeenCalledTimes(3); // Device, Node, Property
95+
expect(updatedHandler).toHaveBeenCalledTimes(1); // Property update
96+
97+
cleanupAndDone();
98+
}
99+
} catch (error) {
100+
cleanupAndDone(error instanceof Error ? error : new Error('An unknown error occurred'));
101+
}
102+
}));
103+
104+
client.publish(`${homiePrefix}/${deviceId}/$state`, 'ready');
105+
106+
// Set a timeout in case we don't receive all expected messages
107+
const timeoutId = setTimeout(() => {
108+
cleanupAndDone(new Error('Timeout: Did not receive all expected messages'));
109+
}, 5000);
110+
111+
// Clear the timeout if the test completes successfully
112+
subscriptions.push(observer.updated$.subscribe(() => {
113+
clearTimeout(timeoutId);
114+
}));
115+
});
116+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as mqtt from 'mqtt';
2+
3+
describe('MQTT Homie Sanity Test', () => {
4+
let client: mqtt.Client;
5+
const brokerUrl = 'mqtt://localhost';
6+
const homiePrefix = `test-homie-${Math.random().toString(36).substring(7)}`;
7+
const deviceId = 'test-device';
8+
9+
beforeAll((done) => {
10+
client = mqtt.connect(brokerUrl);
11+
client.on('connect', () => {
12+
done();
13+
});
14+
});
15+
16+
afterAll((done) => {
17+
if (client.connected) {
18+
client.end(false, {}, () => {
19+
done();
20+
});
21+
} else {
22+
done();
23+
}
24+
});
25+
26+
test('should create a device and receive an update', (done) => {
27+
const deviceTopic = `${homiePrefix}/${deviceId}`;
28+
let messageReceived = false;
29+
30+
const cleanupAndDone = (error?: Error) => {
31+
client.unsubscribe(`${deviceTopic}/#`, () => {
32+
client.removeAllListeners('message');
33+
if (error) {
34+
done(error);
35+
} else {
36+
done();
37+
}
38+
});
39+
};
40+
41+
// Subscribe to device updates
42+
client.subscribe(`${deviceTopic}/#`, undefined, (err) => {
43+
if (err) {
44+
cleanupAndDone(err);
45+
return;
46+
}
47+
48+
// Publish device state
49+
client.publish(`${deviceTopic}/$state`, 'ready', { retain: true }, (err) => {
50+
if (err) {
51+
cleanupAndDone(err);
52+
return;
53+
}
54+
});
55+
});
56+
57+
// Listen for messages
58+
client.on('message', (topic, message) => {
59+
if (topic === `${deviceTopic}/$state` && message.toString() === 'ready') {
60+
messageReceived = true;
61+
expect(messageReceived).toBe(true);
62+
cleanupAndDone();
63+
}
64+
});
65+
66+
// Set a timeout in case we don't receive the message
67+
const timeoutId = setTimeout(() => {
68+
cleanupAndDone(new Error('Timeout: Did not receive device update message'));
69+
}, 5000);
70+
71+
// Clear the timeout if the test completes successfully
72+
client.on('message', () => {
73+
clearTimeout(timeoutId);
74+
});
75+
});
76+
});

jest.integration.config.js

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
module.exports = {
2+
preset: 'ts-jest',
3+
testEnvironment: 'node',
4+
testMatch: ['<rootDir>/integrationtest/**/*.integration.test.ts'],
5+
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
6+
moduleNameMapper: {
7+
'^src/(.*)$': '<rootDir>/src/$1',
8+
},
9+
};

0 commit comments

Comments
 (0)