// Component Terminal - Giao diện điều khiển thiết bị từ xa qua MQTT // Không hỗ trợ thiết bị loại GMSv5 import { getBadgeConnection } from '@/components/shared/ThingShared'; import { ROUTE_MANAGER_DEVICES } from '@/constants/routes'; import { apiGetThingDetail } from '@/services/master/ThingController'; import { mqttClient } from '@/utils/mqttClient'; import { BgColorsOutlined, ClearOutlined } from '@ant-design/icons'; import { PageContainer, ProCard } from '@ant-design/pro-components'; import { history, useIntl, useModel, useParams } from '@umijs/max'; import { Alert, Button, Dropdown, MenuProps, Result, Space, Spin, Typography, theme, } from 'antd'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; import XTerm, { XTermHandle } from './components/XTerm'; // Format SenML record nhận từ MQTT const TERMINAL_THEMES: MasterModel.TerminalThemes = { dark: { name: 'Dark', theme: { background: '#141414', foreground: '#ffffff', cursor: '#ffffff', selectionBackground: 'rgba(255, 255, 255, 0.3)', }, }, light: { name: 'Light', theme: { background: '#ffffff', foreground: '#000000', cursor: '#000000', selectionBackground: 'rgba(0, 0, 0, 0.3)', }, }, green: { name: 'Green (Classic)', theme: { background: '#000000', foreground: '#00ff00', cursor: '#00ff00', selectionBackground: 'rgba(0, 255, 0, 0.3)', }, }, amber: { name: 'Amber', theme: { background: '#1a0f00', foreground: '#ffaa00', cursor: '#ffaa00', selectionBackground: 'rgba(255, 170, 0, 0.3)', }, }, solarized: { name: 'Solarized Dark', theme: { background: '#002b36', foreground: '#839496', cursor: '#93a1a1', selectionBackground: 'rgba(7, 54, 66, 0.99)', }, }, }; /** * Encode chuỗi sang Base64 để gửi qua MQTT * Hỗ trợ Unicode characters qua UTF-8 normalization * Áp dụng cho tất cả thiết bị (ngoại trừ GMSv5) */ const encodeBase64 = (value: string) => { try { const normalized = encodeURIComponent(value).replace( /%([0-9A-F]{2})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)), ); if (typeof globalThis !== 'undefined' && globalThis.btoa) { return globalThis.btoa(normalized); } } catch (error) { console.error('Failed to encode terminal payload', error); } return value; }; /** * Parse payload nhận từ MQTT (format SenML) * Trích xuất các field 'vs' và join thành string hoàn chỉnh */ const parseTerminalPayload = (payload: string): string => { try { const records: MasterModel.SenmlRecord[] = JSON.parse(payload); if (!Array.isArray(records)) { return ''; } return records .map((record) => record?.vs ?? '') .filter(Boolean) .join(''); } catch (error) { console.error('Failed to parse terminal payload', error); return ''; } }; const DeviceTerminalPage = () => { // Lấy thingId từ URL params const { thingId } = useParams<{ thingId: string }>(); const { initialState } = useModel('@@initialState'); const { token } = theme.useToken(); const intl = useIntl(); // States quản lý trạng thái thiết bị và terminal const [thing, setThing] = useState(null); // Thông tin thiết bị const [isLoading, setIsLoading] = useState(false); // Đang load dữ liệu const [terminalError, setTerminalError] = useState(null); // Lỗi terminal const [isSessionReady, setIsSessionReady] = useState(false); // Session đã sẵn sàng const [connectionState, setConnectionState] = useState({ online: false, }); // Trạng thái online/offline const [sessionAttempt, setSessionAttempt] = useState(0); // Số lần thử kết nối lại const [selectedThemeKey, setSelectedThemeKey] = useState('dark'); // Theme được chọn // Refs lưu trữ các giá trị không trigger re-render const sessionIdRef = useRef(uuidv4()); // ID phiên terminal duy nhất const terminalRef = useRef(null); // Reference đến XTerm component const responseTopicRef = useRef(null); // Topic MQTT nhận phản hồi const requestTopicRef = useRef(null); // Topic MQTT gửi yêu cầu const handshakeCompletedRef = useRef(false); // Đã hoàn thành handshake chưa const mqttCleanupRef = useRef void>>([]); // Mảng cleanup functions const credentialMetadata = initialState?.currentUserProfile?.metadata; /** * Lấy MQTT credentials từ metadata user * Username: frontend_thing_id * Password: frontend_thing_key */ const mqttCredentials = useMemo(() => { const username = credentialMetadata?.frontend_thing_id; const password = credentialMetadata?.frontend_thing_key; if (username && password) { return { username, password, }; } return null; }, [credentialMetadata]); // Kiểm tra thiết bị có hỗ trợ terminal không (không hỗ trợ GMSv5) const supportsTerminal = thing?.metadata?.type !== 'gmsv5'; // Channel ID để gửi lệnh điều khiển const ctrlChannelId = thing?.metadata?.ctrl_channel_id; /** * Dọn dẹp MQTT connection khi unmount hoặc reconnect - Hủy subscribe topic - Reset các ref */ const tearDownMqtt = useCallback(() => { mqttCleanupRef.current.forEach((dispose) => { try { dispose(); } catch { // ignore individual cleanup errors } }); mqttCleanupRef.current = []; if (responseTopicRef.current) { mqttClient.unsubscribe(responseTopicRef.current); responseTopicRef.current = null; } requestTopicRef.current = null; handshakeCompletedRef.current = false; }, []); /** * Fetch thông tin chi tiết thiết bị từ API - Cập nhật state thing - Cập nhật trạng thái online/offline - Xóa lỗi nếu fetch thành công */ const fetchThingDetail = useCallback(async () => { if (!thingId) { return; } setIsLoading(true); try { const resp = await apiGetThingDetail(thingId); setThing(resp); const isConnected = Boolean(resp?.metadata?.connected); setConnectionState({ online: isConnected, lastSeen: isConnected ? undefined : Date.now(), }); setTerminalError(null); } catch (error) { console.error('Failed to load device details', error); setTerminalError( intl.formatMessage({ id: 'terminal.loadDeviceError', defaultMessage: 'Không thể tải thông tin thiết bị.', }), ); } finally { setIsLoading(false); } }, [intl, thingId]); // Fetch thông tin thiết bị khi component mount useEffect(() => { fetchThingDetail(); }, [fetchThingDetail]); // Tạo sessionId mới khi thingId thay đổi useEffect(() => { sessionIdRef.current = uuidv4(); }, [thingId]); // Khôi phục theme từ localStorage khi component mount useEffect(() => { const savedTheme = localStorage.getItem('terminal_theme_key'); if (savedTheme && TERMINAL_THEMES[savedTheme]) { setSelectedThemeKey(savedTheme); } }, []); /** * Gửi lệnh tới thiết bị qua MQTT * Format SenML: bn(base name), n(name), bt(base time), vs(value string) */ const publishTerminalCommand = useCallback((command: string) => { const topic = requestTopicRef.current; if (!topic || !command) { return; } const payload: MasterModel.MqttTerminalPayload = [ { bn: `${sessionIdRef.current}:`, n: 'term', bt: Math.floor(Date.now() / 1000), vs: encodeBase64(command), }, ]; mqttClient.publish(topic, JSON.stringify(payload)); }, []); /** * Setup MQTT connection và handlers - Tạo topics để giao tiếp với thiết bị - Đăng ký handlers cho các sự kiện MQTT - Thực hiện handshake khi kết nối thành công */ useEffect(() => { if (!supportsTerminal || !mqttCredentials || !ctrlChannelId) { return; } tearDownMqtt(); // Tạo topics: response topic duy nhất cho mỗi session, request topic chung const responseTopic = `channels/${ctrlChannelId}/messages/res/term/${sessionIdRef.current}`; const requestTopic = `channels/${ctrlChannelId}/messages/req`; responseTopicRef.current = responseTopic; requestTopicRef.current = requestTopic; handshakeCompletedRef.current = false; /** * Handler khi MQTT kết nối thành công - Subscribe response topic - Gửi lệnh handshake: 'open' và 'cd /' */ const handleConnect = () => { setTerminalError(null); if (!handshakeCompletedRef.current) { mqttClient.subscribe(responseTopic); publishTerminalCommand('open'); publishTerminalCommand('cd /'); handshakeCompletedRef.current = true; } setIsSessionReady(true); }; /** * Handler nhận message từ thiết bị - Parse payload SenML - Ghi dữ liệu ra terminal */ const handleMessage = (topic: string, message: Buffer) => { if (topic !== responseTopic) { return; } const text = parseTerminalPayload(message.toString()); if (text) { terminalRef.current?.write(text); } }; /** * Handler khi MQTT bị ngắt kết nối */ const handleClose = () => { setIsSessionReady(false); }; /** * Handler khi có lỗi MQTT - Dọn dẹp connection - Hiển thị thông báo lỗi */ const handleError = (error: Error) => { console.error('MQTT terminal error', error); setIsSessionReady(false); tearDownMqtt(); mqttClient.disconnect(); setTerminalError( intl.formatMessage({ id: 'terminal.mqttError', defaultMessage: 'Không thể kết nối MQTT.', }), ); }; // Kết nối MQTT và đăng ký handlers mqttClient.connect(mqttCredentials); const offConnect = mqttClient.onConnect(handleConnect); const offMessage = mqttClient.onMessage(handleMessage); const offClose = mqttClient.onClose(handleClose); const offError = mqttClient.onError(handleError); mqttCleanupRef.current = [offConnect, offMessage, offClose, offError]; // Nếu đã kết nối thì xử lý luôn if (mqttClient.isConnected()) { handleConnect(); } // Cleanup khi unmount hoặc dependencies thay đổi return () => { tearDownMqtt(); mqttClient.disconnect(); setIsSessionReady(false); }; }, [ ctrlChannelId, intl, mqttCredentials, publishTerminalCommand, sessionAttempt, supportsTerminal, tearDownMqtt, ]); /** * Xử lý khi người dùng nhập liệu vào terminal - Gửi từng ký tự với prefix 'c,' - Chỉ gửi khi session đã sẵn sàng */ const handleTyping = useCallback( (data: string) => { if (!isSessionReady) { return; } publishTerminalCommand(`c,${data}`); }, [isSessionReady, publishTerminalCommand], ); /** * Xử lý retry khi có lỗi - Tạo sessionId mới - Tăng sessionAttempt để trigger reconnect - Fetch lại thông tin thiết bị */ const handleRetry = () => { setTerminalError(null); setSessionAttempt((prev) => prev + 1); sessionIdRef.current = uuidv4(); fetchThingDetail(); }; // Label hiển thị trạng thái kết nối const connectionLabel = connectionState.online ? intl.formatMessage({ id: 'common.online', defaultMessage: 'Online' }) : intl.formatMessage({ id: 'common.offline', defaultMessage: 'Offline' }); /** * Render kết quả blocking (lỗi, warning, info) - Hiển thị nút quay lại và thử lại */ const renderBlockingResult = ( status: 'info' | 'warning' | 'error' | '404', title: string, subTitle?: string, ) => ( history.push(ROUTE_MANAGER_DEVICES)}> {intl.formatMessage({ id: 'common.back', defaultMessage: 'Quay lại', })} , , ]} /> ); // Tiêu đề trang const pageTitle = thing?.name || intl.formatMessage({ id: 'terminal.pageTitle', defaultMessage: 'Terminal', }); /** * Điều kiện hiển thị terminal - Tất cả điều kiện phải true */ const showTerminal = !isLoading && !terminalError && thing && supportsTerminal && ctrlChannelId && mqttCredentials; /** * Theme của terminal theo dark/light mode */ const terminalTheme = useMemo(() => { return ( TERMINAL_THEMES[selectedThemeKey]?.theme || TERMINAL_THEMES.dark.theme ); }, [selectedThemeKey]); /** * Xử lý thay đổi theme * - Cập nhật state * - Lưu vào localStorage */ const handleThemeChange: MenuProps['onClick'] = (e) => { const themeKey = e.key; setSelectedThemeKey(themeKey); localStorage.setItem('terminal_theme_key', themeKey); }; /** * Menu items cho theme selector */ const themeMenuItems: MenuProps['items'] = Object.entries( TERMINAL_THEMES, ).map(([key, preset]) => ({ key, label: preset.name, })); // ===== RENDER ===== return ( history.push(ROUTE_MANAGER_DEVICES), tags: ( {getBadgeConnection(connectionState.online)} {connectionLabel} ), }} > {/* Loading state */} {isLoading && (
)} {/* Generic error state */} {!isLoading && terminalError && ( {intl.formatMessage({ id: 'common.retry', defaultMessage: 'Thử lại', })} , ]} /> )} {/* Thiết bị không hỗ trợ terminal (không phải GMSv6) */} {!isLoading && !terminalError && thing && !supportsTerminal && renderBlockingResult( 'info', intl.formatMessage({ id: 'terminal.unsupported.title', defaultMessage: 'Thiết bị không hỗ trợ terminal', }), intl.formatMessage({ id: 'terminal.unsupported.desc', defaultMessage: 'Thiết bị GMSv5 không được hỗ trợ. Vui lòng sử dụng thiết bị khác.', }), )} {/* Thiếu ctrl_channel_id */} {!isLoading && !terminalError && thing && supportsTerminal && !ctrlChannelId && renderBlockingResult( 'warning', intl.formatMessage({ id: 'terminal.missingChannel.title', defaultMessage: 'Thiếu thông tin kênh điều khiển', }), intl.formatMessage({ id: 'terminal.missingChannel.desc', defaultMessage: 'Thiết bị chưa được cấu hình ctrl_channel_id nên không thể mở terminal.', }), )} {/* Thiếu MQTT credentials */} {!isLoading && !terminalError && thing && supportsTerminal && ctrlChannelId && !mqttCredentials && renderBlockingResult( 'warning', intl.formatMessage({ id: 'terminal.missingCredential.title', defaultMessage: 'Thiếu thông tin xác thực', }), intl.formatMessage({ id: 'terminal.missingCredential.desc', defaultMessage: 'Tài khoản hiện tại chưa được cấp frontend_thing_id/frontend_thing_key.', }), )} {/* Terminal UI - chỉ hiển thị khi tất cả điều kiện thỏa mãn */} {showTerminal && ( {/* Cảnh báo khi thiết bị offline */} {!connectionState.online && ( )} {/* Đang kết nối MQTT */} {connectionState.online && !isSessionReady && ( )} {/* XTerm component */} {/* Nút xóa màn hình */} )}
); }; export default DeviceTerminalPage;