import throttle from 'lodash/throttle'; import dayjs from 'dayjs'; import { wsManager } from '../WebSocketManager.ts'; import { getJurisdiction, releaseControl } from '../../api/device.ts'; import envConfig from '../../../env'; import i18n from '../../i18n'; export default class DeviceController { constructor(userInfo, messageApi, siteId) { this.userInfo = userInfo; this.messageApi = messageApi; this.siteId = siteId; this.wsUrl = envConfig.WS_URL || 'wss://ri.satabot.com/ws/'; this.connectedDeviceSn = null; this.currentDevice = null; this.requestDeviceId = ''; this.isControlled = false; this.isRequestControl = false; this.hasControlRight = false; this.wsStatus = 'disconnected'; this.wsDeviceInfo = {}; this.locationInfo = {}; this.controlTimeout = null; this.keepAliveInterval = null; this.onDeviceInfoUpdate = null; this.onLocationInfoUpdate = null; this.onControlStatusChange = null; this.onWsStatusChange = null; this.onHasControlRightChange = null; this.onRequestControlStatusChange = null; this.onPermissionRequest = null; this.onRoutePost = null; this.deviceStatusChangedMsg = null; this.onIsFinished = null; this.onIsExecuting = null; this.handleArrivedPoint = null; // 全量设备实时状态(mapPush)连接 this.mapPushWs = null; this.mapPushManualClose = false; this.mapPushReconnectTimer = null; this.onMapPush = null; this.handleWsMessage = this.handleWsMessage.bind(this); this.handleWsStatus = this.handleWsStatus.bind(this); this.handleVisibilityChange = this.handleVisibilityChange.bind(this); this.handleBeforeUnload = this.handleBeforeUnload.bind(this); this.sendGamepad = throttle( this.sendGamepad.bind(this), 50, { leading: true, trailing: true } ); } init() { this.unSubMessage = wsManager.onMessage(this.handleWsMessage); this.unSubStatus = wsManager.onStatusChange(this.handleWsStatus); document.addEventListener('visibilitychange', this.handleVisibilityChange); document.addEventListener('beforeunload', this.handleBeforeUnload); } levae() { wsManager.close(); } disconnect() { console.log('DeviceController disconnect: 断开 WebSocket'); wsManager.close(); this.connectedDeviceSn = null; this.currentDevice = null; this.wsDeviceInfo = {}; this.locationInfo = {}; this.isControlled = false; this.onControlStatusChange?.(false); } // ============ 全量设备实时状态(mapPush)连接 ============ // 进入实时监控页时连接一次,接收后端广播的所有设备状态;退出页面时调用 closeMapPush 断开。 initMapPush(onMapPush) { console.log('DeviceController initMapPush: 连接全量设备 WebSocket'); this.onMapPush = onMapPush || null; this.mapPushManualClose = false; this.connectMapPush(); } connectMapPush() { if (this.mapPushWs && this.mapPushWs.readyState === WebSocket.OPEN) return; try { this.mapPushManualClose = false; const ws = new WebSocket(this.wsUrl); this.mapPushWs = ws; ws.onopen = () => { ws.send(JSON.stringify({ type: 'mapPush', token: this.userInfo?.token, siteId: this.siteId, userName: this.userInfo?.username, deviceId: '', })); }; ws.onmessage = (event) => { let msg = null; try { msg = JSON.parse(event.data); } catch (e) { return; } console.log('[DeviceController] 收到全量设备状态 初始状态', msg); if (msg?.type == 'mapPush') { const list = Array.isArray(msg?.data) && msg.data.length ? msg.data : []; if (!list.length) return; const next = {}; list.forEach((it) => { const key = String(it?.id ?? ''); if (!key) return; next[key] = { id: key, lng: String(it?.lng ?? it?.lon ?? it?.longitude), lat: String(it?.lat ?? it?.latitude), yaw: it?.y, fix: it?.f !== undefined ? String(it.f) : undefined, fixStatus: it?.f !== undefined ? String(it.f) : undefined, altitude: it?.a !== undefined ? String(it.a) : undefined, status: it?.s ?? it?.status, taskId: it?.t ?? it?.taskId ?? null, battery: it?.b !== undefined ? Number(it.b) : (it?.batteryLevel !== undefined ? Number(it.batteryLevel) : undefined), hasAlarm: !!it?.ha || !!it?.hasAlarm, updateTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), controlMode: it?.cm ?? it?.controlMode, }; }); console.log('[DeviceController] 收到全量设备状态', next); this.onMapPush?.(next); } if (msg?.type == 'device_task_change') { if (msg.status == "FINISH") { console.log('task finish'); this.onIsFinished?.(true); } if (msg.status == "EXECUTING" || msg.status == "CANCELLED" || msg.status == "FAILED") { this.onIsExecuting?.(true); } } if (msg?.type == 'device_status_changed') { this.deviceStatusChangedMsg?.(msg); } if (msg?.type == 'route_post') { console.log('route_post'); this.onRoutePost?.(msg); } }; ws.onerror = () => { console.error('[DeviceController] mapPush WebSocket 错误'); }; ws.onclose = () => { if (this.mapPushWs === ws) this.mapPushWs = null; if (!this.mapPushManualClose) { this.mapPushReconnectTimer = setTimeout(() => this.connectMapPush(), 5000); } }; } catch (err) { console.error('[DeviceController] 连接全量设备 WebSocket 失败', err); this.mapPushReconnectTimer = setTimeout(() => this.connectMapPush(), 5000); } } closeMapPush() { this.mapPushManualClose = true; if (this.mapPushReconnectTimer) { clearTimeout(this.mapPushReconnectTimer); this.mapPushReconnectTimer = null; } if (this.mapPushWs) { this.mapPushWs.onopen = null; this.mapPushWs.onmessage = null; this.mapPushWs.onerror = null; this.mapPushWs.onclose = null; try { this.mapPushWs.close(); } catch (e) { /* ignore */ } this.mapPushWs = null; } } handleVisibilityChange() { if (document.visibilityState === 'hidden') { console.log('Page hidden, release control'); //this.release(); wsManager.close(); this.connectedDeviceSn = null; this.isControlled = false; this.onControlStatusChange?.(false); } if (document.visibilityState === 'visible' && this.currentDevice) { if (!this.currentDevice?.serialNumber) return; console.log('Page visible, reconnect WebSocket'); setTimeout(() => { this.connectDevice(this.currentDevice); }, 500); } } handleBeforeUnload() { console.log('Browser closing/refreshing, release control'); this.release(); } startKeepAlive() { this.keepAliveInterval = setInterval(() => { if ( this.connectedDeviceSn && this.wsStatus !== 'connected' && this.currentDevice?.serialNumber ) { console.log('Keep alive: reconnect WebSocket'); this.connectDevice(this.currentDevice); } }, 8000); } async release() { if (!this.connectedDeviceSn) return; const data = { platform: 'web', type: 1, deviceId: this.connectedDeviceSn, token: this.userInfo.token, }; const url = `${envConfig.baseURL}/forward/device/releaseControl`; try { await releaseControl(data); const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); navigator.sendBeacon(url, blob); console.log('releaseControl sent'); } catch (e) { console.error('Failed to send releaseControl:', e); } } destroy() { this.clearControlTimeout(); if (this.keepAliveInterval) { clearInterval(this.keepAliveInterval); this.keepAliveInterval = null; } document.removeEventListener('visibilitychange', this.handleVisibilityChange); document.removeEventListener('beforeunload', this.handleBeforeUnload); this.release(); this.levae(); this.unSubMessage?.(); this.unSubStatus?.(); this.connectedDeviceSn = null; } handleWsStatus(status) { this.wsStatus = status; this.onWsStatusChange?.(status); if (status === 'disconnected') { this.isControlled = false; this.onControlStatusChange?.(false); } } async connectDevice(device) { if (!device) return; this.currentDevice = device; if (!device.serialNumber) return; const sn = device.serialNumber; this.connectedDeviceSn = null; this.wsDeviceInfo = {}; this.locationInfo = {}; this.isControlled = false; this.onControlStatusChange?.(false); // 切换任务/设备:先清掉上一个设备的实时坐标,避免轨迹残留到新设备 this.onLocationInfoUpdate?.(null); wsManager.close(); try { console.log('Connecting to', this.wsUrl); this.connectedDeviceSn = sn; wsManager.connect(sn, this.wsUrl); await this.getControl(sn); } catch (err) { console.error(err); this.messageApi.error(i18n.t('devices.failedFixed22')); } } async switchDevice(device) { if (!device) return; if (!device.serialNumber) return; const newSn = device.serialNumber; if (this.connectedDeviceSn === newSn && this.wsStatus === 'connected') { console.log('switchDevice: same device, skip'); return; } console.log('Connecting to switchDevice: switching to', newSn); this.currentDevice = device; this.connectedDeviceSn = newSn; this.wsDeviceInfo = {}; this.locationInfo = {}; this.isControlled = false; this.onControlStatusChange?.(false); // 切换设备:清掉上一个设备的实时坐标,避免轨迹残留 this.onLocationInfoUpdate?.(null); wsManager.send({ type: 'AUTH', token: this.userInfo.token, userName: this.userInfo.username, deviceId: newSn, }); await this.getControl(newSn); } async getControl(serialnum) { try { const res = await getJurisdiction({ platform: 'web', deviceId: serialnum, token: this.userInfo.token, }); this.isControlled = res?.code === 200 && res?.data?.remoteControl === true; this.onControlStatusChange?.(this.isControlled); } catch (err) { console.error(err); this.isControlled = false; this.onControlStatusChange?.(false); } } requestControl() { if (this.isRequestControl) return; this.isRequestControl = true; this.onRequestControlStatusChange?.(true); this.messageApi.loading({ key: 'control', content: i18n.t('devices.control2'), duration: 0, }); this.sendPermissionRequest(true, 'switchPermission'); this.clearControlTimeout(); this.controlTimeout = setTimeout(() => { if (!this.isRequestControl) return; this.isRequestControl = false; this.onRequestControlStatusChange?.(false); this.messageApi.destroy('control'); this.messageApi.error(i18n.t('devices.fixed2ControlFixed2')); this.clearControlTimeout(); }, 20000); } clearControlTimeout() { if (this.controlTimeout) { clearTimeout(this.controlTimeout); this.controlTimeout = null; } } sendPermissionRequest(data, type) { wsManager.send({ type, switchResult: data, token: this.userInfo.token, deviceId: type === 'switchPermission' ? this.connectedDeviceSn : this.requestDeviceId, userName: this.userInfo.username, }); } handleWsMessage(msg) { console.log('WS message:', msg); switch (msg.type) { case 'deviceInfo': { const data = {}; msg.data.forEach((item) => { data[item.name] = item.value; }); // 切换任务/切换设备时,忽略上一个设备遗留的实时数据 if (data?.deviceId && data.deviceId !== this.connectedDeviceSn) { break; } this.wsDeviceInfo = data; this.onDeviceInfoUpdate?.(data); break; } case 'locationInfo': { const data = {}; msg.data.forEach((item) => { data[item.name] = item.value; }); // 切换任务/切换设备时,忽略上一个设备遗留的实时经纬度,只显示当前连接设备的实时轨迹 if (data?.deviceId && data.deviceId !== this.connectedDeviceSn) { break; } this.locationInfo = data; this.onLocationInfoUpdate?.(data); break; } case 'AUTH': { if (msg.isAuth) { this.getControl(this.connectedDeviceSn); } break; } case 'switchResult': { this.clearControlTimeout(); this.isRequestControl = false; this.onRequestControlStatusChange?.(false); if (msg?.success === false || msg?.respond?.switchResult === false) { this.isControlled = false; this.onControlStatusChange?.(false); this.messageApi.error({ key: 'control', content: msg.message || i18n.t('devices.controlFailed') }); return; } this.isControlled = true; this.onControlStatusChange?.(true); this.messageApi.success({ key: 'control', content: i18n.t('devices.fixed2ControlFixed2SuccessFixed2') }); break; } case "device_task_change": { console.log('task change:', msg); if (msg.status == "FINISH") { console.log('task finish'); this.onIsFinished?.(true); } if (msg.status == "EXECUTING") { this.onIsExecuting?.(true); } break; } case "device_task_arrive_point": { if (msg.deviceId != this.connectedDeviceSn) return; this.handleArrivedPoint?.(msg.entity); console.log('arrive point:', msg); break; } case 'switchPermission': { const platform = (msg.platform || '').toLowerCase(); if (!platform || platform === 'null') return; this.requestDeviceId = msg.deviceId; this.onPermissionRequest?.({ title: i18n.t('devices.s2'), content: i18n.t('devices.platformRequestControl', { platform: msg.platform }), }); break; } case 'detectionReport': const { isSecure, obstacle } = msg.data; if (isSecure) return; const type = obstacle?.type; const distance = (obstacle?.distance || 0).toFixed(2); const types = { 0: i18n.t('constants.locationUnknown'), 1: i18n.t('devices.obstaclePerson'), 2: i18n.t('devices.obstacleVehicle'), 3: i18n.t('devices.obstacleFence'), 4: i18n.t('devices.obstaclePothole'), 5: i18n.t('devices.obstacleGrass') }; this.messageApi.warning({ key: 'obstacle-warning', content: i18n.t('devices.obstacleDetectedReport', { obstacle: types[type] ?? i18n.t('devices.unknownObject'), distance }), duration: 3, }); break; case 'route_post': console.log('route_post:', msg); if (msg.deviceId) { this.onRoutePost?.(msg); } break; case 'device_status_changed': this.deviceStatusChangedMsg?.(msg); break; case 'remoteControl': break; default: break; } } sendGamepad(state) { if (!this.isControlled || !this.connectedDeviceSn) return; wsManager.send({ type: 'remoteControl', remoteData: state, token: this.userInfo.token, userName: this.userInfo.username, }); } }