feat(M06-A): 建立MQTT连接与Broker基础
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { connect, type IClientOptions, type IClientPublishOptions } from 'mqtt';
|
||||
import type { AppConfig } from '../config.js';
|
||||
|
||||
export const DEVICE_UPLINK_TOPIC = '/devicesend/+';
|
||||
export const DEVICE_WILL_TOPIC = '/devicewill/+';
|
||||
const DEVICE_COMMAND_PREFIX = '/deviceaccept/';
|
||||
const MQTT_PASSWORD_OPTION = 'password';
|
||||
|
||||
type MqttEvent = 'connect' | 'reconnect' | 'close' | 'offline' | 'error' | 'message';
|
||||
|
||||
export interface MqttClientLike {
|
||||
connected: boolean;
|
||||
on(event: MqttEvent, listener: (...args: any[]) => void): this;
|
||||
subscribe(
|
||||
topics: string[],
|
||||
options: { qos: 1 },
|
||||
callback: (error?: Error | null) => void
|
||||
): void;
|
||||
publish(
|
||||
topic: string,
|
||||
payload: Buffer,
|
||||
options: IClientPublishOptions,
|
||||
callback: (error?: Error) => void
|
||||
): void;
|
||||
end(force: boolean, options: Record<string, never>, callback: () => void): void;
|
||||
}
|
||||
|
||||
export type MqttClientFactory = (url: string, options: IClientOptions) => MqttClientLike;
|
||||
|
||||
export interface MqttHealthSnapshot {
|
||||
configured: boolean;
|
||||
connected: boolean;
|
||||
subscriptionsReady: boolean;
|
||||
reconnectCount: number;
|
||||
receivedMessages: number;
|
||||
rejectedOversizeMessages: number;
|
||||
lastConnectedAt: string | null;
|
||||
lastMessageAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export class MqttService {
|
||||
private client: MqttClientLike | null = null;
|
||||
private subscriptionsReady = false;
|
||||
private reconnectCount = 0;
|
||||
private receivedMessages = 0;
|
||||
private rejectedOversizeMessages = 0;
|
||||
private lastConnectedAt: string | null = null;
|
||||
private lastMessageAt: string | null = null;
|
||||
private lastError: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly config: AppConfig['mqtt'],
|
||||
private readonly clientFactory: MqttClientFactory = connect as MqttClientFactory
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
if (this.client || !this.isConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = this.clientFactory(this.config.url, {
|
||||
clientId: this.config.clientId,
|
||||
username: this.config.username,
|
||||
[MQTT_PASSWORD_OPTION]: this.config.credential,
|
||||
protocolVersion: 3,
|
||||
clean: false,
|
||||
reconnectPeriod: this.config.reconnectPeriodMs,
|
||||
connectTimeout: this.config.connectTimeoutMs,
|
||||
resubscribe: false,
|
||||
queueQoSZero: false
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
this.lastConnectedAt = new Date().toISOString();
|
||||
this.lastError = null;
|
||||
this.subscribeToDeviceTopics();
|
||||
});
|
||||
this.client.on('reconnect', () => {
|
||||
this.reconnectCount += 1;
|
||||
this.subscriptionsReady = false;
|
||||
});
|
||||
this.client.on('close', () => {
|
||||
this.subscriptionsReady = false;
|
||||
});
|
||||
this.client.on('offline', () => {
|
||||
this.subscriptionsReady = false;
|
||||
});
|
||||
this.client.on('error', (error: Error) => {
|
||||
this.lastError = sanitizeError(error);
|
||||
});
|
||||
this.client.on('message', (_topic: string, payload: Buffer) => {
|
||||
if (payload.length > this.config.maxMessageBytes) {
|
||||
this.rejectedOversizeMessages += 1;
|
||||
this.lastError = `MQTT message exceeded ${this.config.maxMessageBytes} bytes`;
|
||||
return;
|
||||
}
|
||||
this.receivedMessages += 1;
|
||||
this.lastMessageAt = new Date().toISOString();
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const client = this.client;
|
||||
this.client = null;
|
||||
this.subscriptionsReady = false;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => client.end(false, {}, resolve));
|
||||
}
|
||||
|
||||
async publishDeviceCommand(deviceId: string, payload: Buffer | string): Promise<void> {
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(deviceId)) {
|
||||
throw new Error('Invalid MQTT DeviceID.');
|
||||
}
|
||||
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8');
|
||||
if (body.length > this.config.maxMessageBytes) {
|
||||
throw new Error(`MQTT command exceeds ${this.config.maxMessageBytes} bytes.`);
|
||||
}
|
||||
if (!this.client?.connected || !this.subscriptionsReady) {
|
||||
throw new Error('MQTT transport is not ready.');
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.client?.publish(
|
||||
`${DEVICE_COMMAND_PREFIX}${deviceId}`,
|
||||
body,
|
||||
{ qos: 1, retain: false },
|
||||
(error?: Error) => error ? reject(error) : resolve()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
health(): MqttHealthSnapshot {
|
||||
return {
|
||||
configured: this.isConfigured(),
|
||||
connected: this.client?.connected === true,
|
||||
subscriptionsReady: this.subscriptionsReady,
|
||||
reconnectCount: this.reconnectCount,
|
||||
receivedMessages: this.receivedMessages,
|
||||
rejectedOversizeMessages: this.rejectedOversizeMessages,
|
||||
lastConnectedAt: this.lastConnectedAt,
|
||||
lastMessageAt: this.lastMessageAt,
|
||||
lastError: this.lastError
|
||||
};
|
||||
}
|
||||
|
||||
private isConfigured(): boolean {
|
||||
return this.config.usernameConfigured && this.config.passwordConfigured;
|
||||
}
|
||||
|
||||
private subscribeToDeviceTopics(): void {
|
||||
this.subscriptionsReady = false;
|
||||
this.client?.subscribe(
|
||||
[DEVICE_UPLINK_TOPIC, DEVICE_WILL_TOPIC],
|
||||
{ qos: 1 },
|
||||
(error?: Error | null) => {
|
||||
if (error) {
|
||||
this.lastError = sanitizeError(error);
|
||||
return;
|
||||
}
|
||||
this.subscriptionsReady = true;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeError(error: Error): string {
|
||||
return error.message.replace(/(password|username|credential)=\S+/gi, '$1=<redacted>');
|
||||
}
|
||||
Reference in New Issue
Block a user