feat: add MQTT client for camera configuration and enhance camera management

This commit is contained in:
2026-02-08 11:58:57 +07:00
parent 78162fc0cb
commit d619534a73
14 changed files with 1254 additions and 111 deletions

View File

@@ -108,6 +108,8 @@ const LoginPage = () => {
setInitialState((s: any) => ({
...s,
currentUserProfile: userInfo,
theme:
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light',
}));
});
}
@@ -148,6 +150,9 @@ const LoginPage = () => {
setInitialState((s: any) => ({
...s,
currentUserProfile: userInfo,
theme:
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') ||
'light',
}));
});
}
@@ -175,6 +180,9 @@ const LoginPage = () => {
setInitialState((s: any) => ({
...s,
currentUserProfile: userInfo,
theme:
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') ||
'light',
}));
});
}

View File

@@ -8,6 +8,7 @@ import {
Row,
Select,
} from 'antd';
import { useEffect } from 'react';
// Camera types
const CAMERA_TYPES = [
@@ -31,20 +32,58 @@ interface CameraFormValues {
interface CameraFormModalProps {
open: boolean;
onCancel: () => void;
onSubmit: (values: CameraFormValues) => void;
onSubmit: (camera: MasterModel.Camera) => void;
isOnline?: boolean;
editingCamera?: MasterModel.Camera | null;
}
const CameraFormModal: React.FC<CameraFormModalProps> = ({
open,
onCancel,
onSubmit,
isOnline = true,
editingCamera,
}) => {
const [form] = Form.useForm<CameraFormValues>();
const isEditMode = !!editingCamera;
// Populate form when editing
useEffect(() => {
if (open && editingCamera) {
form.setFieldsValue({
name: editingCamera.name || '',
type: editingCamera.cate_id || 'HIKVISION',
account: editingCamera.username || '',
password: editingCamera.password || '',
ipAddress: editingCamera.ip || '',
rtspPort: editingCamera.rtsp_port || 554,
httpPort: editingCamera.http_port || 80,
stream: editingCamera.stream || 0,
channel: editingCamera.channel || 0,
});
} else if (open) {
form.resetFields();
}
}, [open, editingCamera, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
onSubmit(values);
// Convert form values to MasterModel.Camera format
const camera: MasterModel.Camera = {
// Keep existing ID when editing, generate new ID when creating
id: editingCamera?.id || `cam_${Date.now()}`,
name: values.name,
cate_id: values.type,
ip: values.ipAddress,
rtsp_port: values.rtspPort,
http_port: values.httpPort,
stream: values.stream,
channel: values.channel,
username: values.account,
password: values.password,
};
onSubmit(camera);
form.resetFields();
} catch (error) {
console.error('Validation failed:', error);
@@ -58,7 +97,7 @@ const CameraFormModal: React.FC<CameraFormModalProps> = ({
return (
<Modal
title="Tạo mới camera"
title={isEditMode ? 'Chỉnh sửa camera' : 'Tạo mới camera'}
open={open}
onCancel={handleCancel}
footer={[
@@ -66,7 +105,7 @@ const CameraFormModal: React.FC<CameraFormModalProps> = ({
Hủy
</Button>,
<Button key="submit" type="primary" onClick={handleSubmit}>
Đng ý
{isEditMode ? 'Cập nhật' : 'Đồng ý'}
</Button>,
]}
width={500}

View File

@@ -4,22 +4,30 @@ import {
PlusOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Button, Card, Checkbox, Space, Table, theme } from 'antd';
import { Button, Card, Space, Table, theme, Tooltip } from 'antd';
import { useState } from 'react';
interface CameraTableProps {
cameraData: MasterModel.Camera[] | null;
onCreateCamera: () => void;
onEditCamera?: (camera: MasterModel.Camera) => void;
onDeleteCameras?: (cameraIds: string[]) => void;
onReload?: () => void;
loading?: boolean;
isOnline?: boolean;
}
const CameraTable: React.FC<CameraTableProps> = ({
cameraData,
onCreateCamera,
onEditCamera,
onDeleteCameras,
onReload,
loading = false,
isOnline = false,
}) => {
const { token } = theme.useToken();
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const handleReload = () => {
console.log('Reload cameras');
@@ -27,22 +35,18 @@ const CameraTable: React.FC<CameraTableProps> = ({
};
const handleDelete = () => {
console.log('Delete selected cameras');
// TODO: Implement delete functionality
if (selectedRowKeys.length === 0) {
return;
}
onDeleteCameras?.(selectedRowKeys as string[]);
setSelectedRowKeys([]);
};
const handleEdit = (camera: MasterModel.Camera) => {
console.log('Edit camera:', camera);
// TODO: Implement edit functionality
onEditCamera?.(camera);
};
const columns = [
{
title: '',
dataIndex: 'checkbox',
width: 50,
render: () => <Checkbox />,
},
{
title: 'Tên',
dataIndex: 'name',
@@ -67,11 +71,14 @@ const CameraTable: React.FC<CameraTableProps> = ({
title: 'Thao tác',
key: 'action',
render: (_: any, record: MasterModel.Camera) => (
<Button
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
<Tooltip title={!isOnline ? 'Thiết bị đang ngoại tuyến' : ''}>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
disabled={!isOnline}
/>
</Tooltip>
),
},
];
@@ -79,11 +86,29 @@ const CameraTable: React.FC<CameraTableProps> = ({
return (
<Card bodyStyle={{ padding: 16 }}>
<Space style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateCamera}>
Tạo mới camera
</Button>
<Button icon={<ReloadOutlined />} onClick={handleReload} />
<Button icon={<DeleteOutlined />} onClick={handleDelete} />
<Tooltip title={!isOnline ? 'Thiết bị đang ngoại tuyến' : ''}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onCreateCamera}
disabled={!isOnline}
>
Tạo mới camera
</Button>
</Tooltip>
<Button
icon={<ReloadOutlined />}
onClick={handleReload}
loading={loading}
/>
<Tooltip title={!isOnline ? 'Thiết bị đang ngoại tuyến' : ''}>
<Button
icon={<DeleteOutlined />}
onClick={handleDelete}
disabled={!isOnline || selectedRowKeys.length === 0}
danger
/>
</Tooltip>
</Space>
<Table
@@ -92,6 +117,12 @@ const CameraTable: React.FC<CameraTableProps> = ({
rowKey="id"
size="small"
loading={loading}
rowSelection={{
type: 'checkbox',
selectedRowKeys,
onChange: (newSelectedRowKeys) =>
setSelectedRowKeys(newSelectedRowKeys),
}}
pagination={{
size: 'small',
showTotal: (total: number, range: [number, number]) =>

View File

@@ -1,6 +1,15 @@
import { apiQueryConfigAlarm } from '@/services/master/MessageController';
import { useModel } from '@umijs/max';
import { Button, Card, Col, Row, Select, theme, Typography } from 'antd';
import {
Button,
Card,
Col,
Row,
Select,
theme,
Tooltip,
Typography,
} from 'antd';
import { useEffect, useState } from 'react';
const { Text } = Typography;
@@ -15,15 +24,24 @@ const RECORDING_MODES = [
interface CameraV6Props {
thing: MasterModel.Thing | null;
cameraConfig?: MasterModel.CameraV6 | null;
onSubmit?: (config: {
recordingMode: MasterModel.CameraV6['record_type'];
selectedAlerts: string[];
}) => void;
isOnline?: boolean;
}
const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
const CameraV6: React.FC<CameraV6Props> = ({
thing,
cameraConfig,
onSubmit,
isOnline = false,
}) => {
const { token } = theme.useToken();
const { initialState } = useModel('@@initialState');
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
const [recordingMode, setRecordingMode] = useState<'none' | 'alarm' | 'all'>(
'none',
);
const [recordingMode, setRecordingMode] =
useState<MasterModel.CameraV6['record_type']>('none');
const [alarmConfig, setAlarmConfig] = useState<MasterModel.Alarm[] | null>(
null,
);
@@ -95,31 +113,39 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
setSelectedAlerts([]);
};
const handleSubmitAlerts = () => {
console.log('Submit alerts:', {
const handleSubmitConfig = () => {
onSubmit?.({
recordingMode,
selectedAlerts,
});
// TODO: Call API to save alert configuration
};
return (
<Card className="p-4">
{/* Recording Mode */}
<div className="mb-6">
<Text strong className="block mb-2">
Ghi dữ liệu camera
</Text>
<div className="flex gap-8 items-center">
<Select
value={recordingMode}
onChange={setRecordingMode}
options={RECORDING_MODES}
className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4"
/>
<Button type="primary" onClick={handleSubmitAlerts}>
Gửi đi
</Button>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 items-start sm:items-end">
<div className="w-full sm:w-1/3 lg:w-1/4">
<Text strong className="block mb-2">
Ghi dữ liệu camera
</Text>
<Select
value={recordingMode}
onChange={setRecordingMode}
options={RECORDING_MODES}
className="w-full"
popupMatchSelectWidth={false}
/>
</div>
<Tooltip title={!isOnline ? 'Thiết bị đang ngoại tuyến' : ''}>
<Button
type="primary"
onClick={handleSubmitConfig}
disabled={!isOnline}
>
Gửi đi
</Button>
</Tooltip>
</div>
</div>
@@ -155,7 +181,7 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
size="small"
hoverable
onClick={() => handleAlertToggle(alarmId)}
className="cursor-pointer h-20 flex items-center justify-center"
className="cursor-pointer h-24 flex items-center justify-center"
style={{
borderColor: isSelected
? token.colorPrimary
@@ -166,14 +192,21 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
: token.colorBgContainer,
}}
>
<div className="p-2 text-center w-full">
<div className="p-1 text-center w-full flex items-center justify-center h-full">
<Text
className="text-xs break-words"
className="text-xs"
style={{
color: isSelected
? token.colorPrimary
: token.colorText,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
lineHeight: '1.2em',
}}
title={alarm.name}
>
{alarm.name}
</Text>

View File

@@ -1,11 +1,12 @@
import { apiQueryCamera } from '@/services/master/MessageController';
import { apiGetThingDetail } from '@/services/master/ThingController';
import { wsClient } from '@/utils/wsClient';
import { mqttClient } from '@/utils/mqttClient';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { history, useModel, useParams } from '@umijs/max';
import { Button, Col, Row, Space, Spin } from 'antd';
import { Badge, Button, Col, message, Row, Space, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import CameraFormModal from './components/CameraFormModal';
import CameraTable from './components/CameraTable';
import ConfigCameraV5 from './components/ConfigCameraV5';
@@ -21,18 +22,53 @@ const CameraConfigPage = () => {
const [cameraConfig, setCameraConfig] = useState<MasterModel.CameraV6 | null>(
null,
);
const [mqttConnected, setMqttConnected] = useState(false);
const [isOnline, setIsOnline] = useState(false);
const [editingCamera, setEditingCamera] = useState<MasterModel.Camera | null>(
null,
);
const { initialState } = useModel('@@initialState');
// Initialize MQTT connection
useEffect(() => {
wsClient.connect('/mqtt', false);
const unsubscribe = wsClient.subscribe((data: any) => {
console.log('Received WS data:', data);
const { frontend_thing_id, frontend_thing_key } =
initialState?.currentUserProfile?.metadata || {};
if (!frontend_thing_id || !frontend_thing_key) return;
// Connect using mqttClient utility
mqttClient.connect({
username: frontend_thing_id,
password: frontend_thing_key,
});
const unConnect = mqttClient.onConnect(() => {
console.log('MQTT Connected successfully!');
setMqttConnected(true);
});
const unError = mqttClient.onError((error) => {
console.error('MQTT Error:', error);
setMqttConnected(false);
});
const unClose = mqttClient.onClose(() => {
console.log('MQTT Connection closed');
setMqttConnected(false);
});
return () => {
unsubscribe();
unConnect();
unError();
unClose();
mqttClient.disconnect();
};
}, []);
}, [initialState]);
// Check device online status using connected property
useEffect(() => {
setIsOnline(thing?.metadata?.connected ?? false);
}, [thing]);
// Fetch thing info on mount
useEffect(() => {
@@ -99,14 +135,120 @@ const CameraConfigPage = () => {
const handleCloseModal = () => {
setIsModalVisible(false);
setEditingCamera(null);
};
const handleSubmitCamera = (values: any) => {
console.log('Camera values:', values);
// TODO: Call API to create camera
// Core function to send camera config via MQTT
const sendCameraConfig = (configPayload: MasterModel.CameraV6) => {
// Check if device is online
if (!isOnline) {
message.error('Thiết bị đang ngoại tuyến, không thể gửi cấu hình');
return false;
}
if (!thing?.metadata?.cfg_channel_id || !thing?.metadata?.external_id) {
message.error('Thiếu thông tin cấu hình thiết bị');
return false;
}
const { cfg_channel_id, external_id } = thing.metadata;
const pubTopic = `channels/${cfg_channel_id}/messages/cameraconfig/${thing.metadata?.type}`;
const mac = external_id?.replaceAll('-', '');
const ack = uuidv4();
const now = Date.now() / 1000;
const senml: MasterModel.CameraV6ConfigRequest[] = [
{
bn: `urn:dev:mac:${mac?.toLowerCase()}:`,
n: 'ack',
t: now,
vs: ack,
},
{
n: initialState?.currentUserProfile?.email?.replaceAll('@', ':') || '',
vs: JSON.stringify(configPayload),
},
];
const payload = JSON.stringify(senml);
if (mqttClient.isConnected()) {
mqttClient.publish(pubTopic, payload);
message.success('Đã gửi cấu hình thành công');
return true;
} else {
message.error('MQTT chưa kết nối');
return false;
}
};
// Handle camera list config submission (add/edit/delete cameras)
const handleSubmitCameraConfig = (updatedCams: MasterModel.Camera[]) => {
const configPayload: MasterModel.CameraV6 = {
cams: updatedCams,
record_type: cameraConfig?.record_type || 'all',
...(cameraConfig?.record_type === 'alarm' && {
record_alarm_list: cameraConfig?.record_alarm_list || [],
}),
};
if (sendCameraConfig(configPayload)) {
// Update local state
setCameraConfig(configPayload);
setCameras(updatedCams);
}
};
// Handle open edit modal - open modal with camera data for editing
const handleOpenEditModal = (camera: MasterModel.Camera) => {
setEditingCamera(camera);
setIsModalVisible(true);
};
// Handle submit camera (add or edit)
const handleSubmitCamera = (camera: MasterModel.Camera) => {
if (editingCamera) {
// Edit mode: update existing camera
const updatedCams = (cameraConfig?.cams || []).map((cam) =>
cam.id === camera.id ? camera : cam,
);
handleSubmitCameraConfig(updatedCams);
} else {
// Add mode: add new camera
const updatedCams = [...(cameraConfig?.cams || []), camera];
handleSubmitCameraConfig(updatedCams);
}
handleCloseModal();
};
// Handle delete cameras - submit config with cameras removed
const handleDeleteCameras = (cameraIds: string[]) => {
const updatedCams = (cameraConfig?.cams || []).filter(
(cam) => !cameraIds.includes(cam.id || ''),
);
handleSubmitCameraConfig(updatedCams);
};
// Handle recording config submission from ConfigCameraV6
const handleSubmitConfig = (configPayload: {
recordingMode: MasterModel.CameraV6['record_type'];
selectedAlerts: string[];
}) => {
// Chỉ gửi record_alarm_list khi record_type = alarm
const fullConfig: MasterModel.CameraV6 = {
cams: cameraConfig?.cams || [],
record_type: configPayload.recordingMode,
...(configPayload.recordingMode === 'alarm' && {
record_alarm_list: configPayload.selectedAlerts,
}),
};
if (sendCameraConfig(fullConfig)) {
// Update local state
setCameraConfig(fullConfig);
}
};
// Helper function to determine which camera component to render
const renderCameraRecordingComponent = () => {
const thingType = thing?.metadata?.type;
@@ -116,10 +258,24 @@ const CameraConfigPage = () => {
}
if (thingType === 'spole' || thingType === 'gmsv6') {
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
return (
<ConfigCameraV6
thing={thing}
cameraConfig={cameraConfig}
onSubmit={handleSubmitConfig}
isOnline={isOnline}
/>
);
}
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
return (
<ConfigCameraV6
thing={thing}
cameraConfig={cameraConfig}
onSubmit={handleSubmitConfig}
isOnline={isOnline}
/>
);
};
return (
@@ -134,6 +290,10 @@ const CameraConfigPage = () => {
onClick={() => history.push('/manager/devices')}
/>
<span>{thing?.name || 'Loading...'}</span>
<Badge
status={isOnline ? 'success' : 'default'}
text={isOnline ? 'Trực tuyến' : 'Ngoại tuyến'}
/>
</Space>
),
}}
@@ -144,8 +304,11 @@ const CameraConfigPage = () => {
<CameraTable
cameraData={cameras}
onCreateCamera={handleOpenModal}
onEditCamera={handleOpenEditModal}
onDeleteCameras={handleDeleteCameras}
onReload={fetchCameraConfig}
loading={cameraLoading}
isOnline={isOnline}
/>
</Col>
@@ -155,11 +318,13 @@ const CameraConfigPage = () => {
</Col>
</Row>
{/* Create Camera Modal */}
{/* Create/Edit Camera Modal */}
<CameraFormModal
open={isModalVisible}
onCancel={handleCloseModal}
onSubmit={handleSubmitCamera}
isOnline={isOnline}
editingCamera={editingCamera}
/>
</PageContainer>
</Spin>

View File

@@ -5,7 +5,10 @@ export async function apiQueryLogs(
params: MasterModel.SearchLogPaginationBody,
type: MasterModel.LogTypeRequest,
) {
return request<MasterModel.LogResponse>(`${API_READER}/${type}/messages`, {
params: params,
});
return request<MasterModel.MesageReaderResponse>(
`${API_READER}/${type}/messages`,
{
params: params,
},
);
}

36
src/services/master/typings/camera.d.ts vendored Normal file
View File

@@ -0,0 +1,36 @@
declare namespace MasterModel {
interface Camera {
id?: string;
name?: string;
cate_id?: string;
username?: string;
password?: string;
rtsp_port?: number;
http_port?: number;
channel?: number;
ip?: string;
stream?: number;
}
interface CameraV5 {
cams?: Camera[];
}
interface CameraV6 extends CameraV5 {
record_type?: 'none' | 'alarm' | 'all';
record_alarm_list?: string[];
}
type CameraMessage = Message<CameraV5>;
type CameraV6Message = Message<CameraV6>;
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
interface CameraV6ConfigRequest {
bn?: string;
n?: string;
t?: number;
vs?: string | CameraV6;
}
}

View File

@@ -29,12 +29,10 @@ declare namespace MasterModel {
}
// Response types cho từng domain
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
type AlarmMessageResponse = MesageReaderResponse<Alarm>;
type NodeConfigMessageResponse = MesageReaderResponse<NodeConfig[]>;
type MessageDataType = NodeConfig[] | CameraV5 | CameraV6;
type MessageDataType = NodeConfig[];
interface Message<T = MessageDataType> extends MessageBasicInfo {
string_value?: string;
@@ -42,31 +40,8 @@ declare namespace MasterModel {
}
// Message types cho từng domain
type CameraMessage = Message<CameraV5>;
type CameraV6Message = Message<CameraV6>;
type NodeConfigMessage = Message<NodeConfig[]>;
interface CameraV5 {
cams?: Camera[];
}
interface CameraV6 extends CameraV5 {
record_type?: 'none' | 'alarm' | 'all';
record_alarm_list?: string[];
}
interface Camera {
id?: string;
name?: string;
cate_id?: string;
username?: string;
password?: string;
rtsp_port?: number;
http_port?: number;
channel?: number;
ip?: string;
stream?: number;
}
interface Alarm {
id: string;
type: Type;

151
src/utils/mqttClient.ts Normal file
View File

@@ -0,0 +1,151 @@
import mqtt, { IClientOptions, MqttClient, Packet } from 'mqtt';
type MessageHandler = (topic: string, message: Buffer, packet: Packet) => void;
type ConnectionHandler = () => void;
type ErrorHandler = (error: Error) => void;
interface MqttCredentials {
username: string;
password: string;
}
class MQTTClientManager {
private client: MqttClient | null = null;
private messageHandlers = new Set<MessageHandler>();
private connectHandlers = new Set<ConnectionHandler>();
private closeHandlers = new Set<ConnectionHandler>();
private errorHandlers = new Set<ErrorHandler>();
/**
* Kết nối tới MQTT broker.
* @param credentials Thông tin xác thực (username, password)
* @param url Địa chỉ MQTT broker (mặc định là /mqtt)
*/
connect(credentials: MqttCredentials, url: string = '/mqtt') {
if (this.client?.connected) return;
// Build WebSocket URL
let mqttUrl = url;
if (url.startsWith('/')) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
mqttUrl = `${protocol}//${window.location.host}${url}`;
}
const opts: IClientOptions = {
clean: true,
username: credentials.username,
password: credentials.password,
reconnectPeriod: 5000,
connectTimeout: 30 * 1000,
};
this.client = mqtt.connect(mqttUrl, opts);
this.client.on('connect', () => {
console.log('MQTT Connected successfully!');
this.connectHandlers.forEach((fn) => fn());
});
this.client.on('close', () => {
console.log('MQTT Connection closed');
this.closeHandlers.forEach((fn) => fn());
});
this.client.on('error', (error: Error) => {
console.error('MQTT Error:', error);
this.errorHandlers.forEach((fn) => fn(error));
});
this.client.on('message', (topic, message, packet) => {
this.messageHandlers.forEach((fn) => fn(topic, message, packet));
});
}
/**
* Ngắt kết nối MQTT và giải phóng tài nguyên.
*/
disconnect() {
if (this.client) {
this.client.end();
this.client = null;
}
}
/**
* Subscribe vào một topic.
* @param topic Topic cần subscribe
*/
subscribe(topic: string | string[]) {
this.client?.subscribe(topic);
}
/**
* Unsubscribe khỏi một topic.
* @param topic Topic cần unsubscribe
*/
unsubscribe(topic: string | string[]) {
this.client?.unsubscribe(topic);
}
/**
* Publish message tới một topic.
* @param topic Topic để publish
* @param payload Payload (string hoặc object sẽ được stringify)
*/
publish(topic: string, payload: string | object) {
const payloadStr =
typeof payload === 'string' ? payload : JSON.stringify(payload);
this.client?.publish(topic, payloadStr);
}
/**
* Đăng ký callback khi nhận được message.
* @param cb Hàm callback xử lý message
* @returns Hàm hủy đăng ký callback
*/
onMessage(cb: MessageHandler) {
this.messageHandlers.add(cb);
return () => this.messageHandlers.delete(cb);
}
/**
* Đăng ký callback khi kết nối thành công.
*/
onConnect(cb: ConnectionHandler) {
this.connectHandlers.add(cb);
return () => this.connectHandlers.delete(cb);
}
/**
* Đăng ký callback khi kết nối bị đóng.
*/
onClose(cb: ConnectionHandler) {
this.closeHandlers.add(cb);
return () => this.closeHandlers.delete(cb);
}
/**
* Đăng ký callback khi có lỗi.
*/
onError(cb: ErrorHandler) {
this.errorHandlers.add(cb);
return () => this.errorHandlers.delete(cb);
}
/**
* Kiểm tra trạng thái kết nối MQTT.
* @returns true nếu đã kết nối, ngược lại là false
*/
isConnected() {
return this.client?.connected ?? false;
}
/**
* Lấy instance client MQTT gốc (nếu cần thao tác nâng cao).
*/
getClient() {
return this.client;
}
}
export const mqttClient = new MQTTClientManager();