feat(M06-A): 建立MQTT连接与Broker基础

This commit is contained in:
Codex
2026-06-22 17:25:33 +08:00
parent a8c3e7d2fe
commit c140613718
15 changed files with 875 additions and 36 deletions
+171
View File
@@ -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>');
}