diff --git a/entry/src/main/ets/pages/BluetoothControlPage.ets b/entry/src/main/ets/pages/BluetoothControlPage.ets index b4e1e5e..b44d3e8 100644 --- a/entry/src/main/ets/pages/BluetoothControlPage.ets +++ b/entry/src/main/ets/pages/BluetoothControlPage.ets @@ -1,995 +1,156 @@ -/** - * 蓝牙设备控制页面 - * 功能:手动控制、参数设置、自动模式、报警浏览 - */ import { router } from '@kit.ArkUI'; -import { connection, socket } from '@kit.ConnectivityKit'; -import { BusinessError } from '@kit.BasicServicesKit'; - -// 路由参数接口 -interface RouterParams { - deviceId: string; - deviceName: string; -} - -// 回路信息接口 -interface LoopInfo { - index: number; - name: string; - isOpen: boolean; - pwm: number; -} - -// Tab项接口 -interface TabItem { - name: string; - index: number; -} - -// 加载状态接口 -interface LoadingState { - deviceTime: boolean; - deviceInfo: boolean; - sunRiseTime: boolean; - settingDeviceInfo: boolean; -} +import { M9zService } from '../utils/M9zService'; +import { ManualControl } from './components/ManualControl'; +import { ParameterSettings } from './components/ParameterSettings'; +import { AutoMode } from './components/AutoMode'; +import { AlarmBrowser } from './components/AlarmBrowser'; @Entry @Component struct BluetoothControlPage { + @State deviceName: string = '未知设备'; @State deviceId: string = ''; - @State deviceName: string = ''; - @State activeTab: number = 0; - @State isManualMode: boolean = false; - @State deviceTime: string = '--:--:--'; - @State loops: LoopInfo[] = []; - @State currentBrightness: number = 50; - @State showBrightnessDialog: boolean = false; - @State selectedLoopIndex: number = -1; - @State sunriseTime: string = '--:--'; - @State sunsetTime: string = '--:--'; - @State isLoading: boolean = false; - @State toastMessage: string = ''; - @State showToast: boolean = false; - @State showBackDialog: boolean = false; @State isConnected: boolean = false; + @State statusMessage: string = '正在连接...'; + @State currentIndex: number = 0; - // 加载状态 - @State dataLoading: LoadingState = { - deviceTime: false, - deviceInfo: false, - sunRiseTime: false, - settingDeviceInfo: false - }; - - private tabs: TabItem[] = [ - { name: '手动控制', index: 0 }, - { name: '参数设置', index: 1 }, - { name: '自动模式', index: 2 }, - { name: '报警浏览', index: 3 }, - ]; - - // SPP UUID (串口通信) - private readonly SPP_UUID: string = '00001101-0000-1000-8000-00805F9B34FB'; - private clientNumber: number = -1; - - aboutToAppear(): void { - // 获取路由参数 - const params = router.getParams() as RouterParams; + private m9zService: M9zService = M9zService.getInstance(); + private controller: TabsController = new TabsController(); + + aboutToAppear() { + const params = router.getParams() as Record; if (params) { - this.deviceId = params.deviceId || ''; this.deviceName = params.deviceName || '未知设备'; + this.deviceId = params.deviceId; } - - // 初始化10个回路 - this.initLoops(); - - // 连接设备 - this.connectSpp(); - } - - aboutToDisappear(): void { - this.disconnectSpp(); - } - - // 显示Toast - showToastMessage(message: string): void { - this.toastMessage = message; - this.showToast = true; - setTimeout(() => { - this.showToast = false; - }, 2000); - } - - // 初始化回路 - initLoops(): void { - this.loops = []; - for (let i = 0; i < 10; i++) { - this.loops.push({ - index: i, - name: `#${i + 1}`, - isOpen: false, - pwm: 0, - }); - } - } - - // 连接 SPP(串口协议) - connectSpp(): void { - if (!this.deviceId) { - console.error('设备ID为空'); - return; - } - - try { - // 使用 SPP 连接 - socket.sppConnect(this.deviceId, { - uuid: this.SPP_UUID, - secure: true, - type: socket.SppType.SPP_RFCOMM - }, (err: BusinessError, clientNumber: number) => { - if (err) { - console.error('SPP 连接失败:', JSON.stringify(err)); - this.showToastMessage('SPP 连接失败'); - return; - } - - this.clientNumber = clientNumber; - this.isConnected = true; - console.info('SPP 连接成功, clientNumber:', clientNumber); - - // 监听数据接收 - socket.on('sppRead', this.clientNumber, (data: ArrayBuffer) => { - this.handleReceivedData(data); - }); - - // 获取初始数据 - setTimeout(() => { - this.getDeviceTime(); - this.getManualControlInfo(); - }, 500); - }); - - } catch (err) { - console.error('SPP 连接异常:', JSON.stringify(err)); - this.showToastMessage('SPP 连接异常'); - } - } - - // 断开 SPP 连接 - disconnectSpp(): void { - if (this.clientNumber >= 0) { - try { - socket.off('sppRead', this.clientNumber); - socket.sppCloseClientSocket(this.clientNumber); - this.clientNumber = -1; - this.isConnected = false; - console.info('SPP 断开成功'); - } catch (err) { - console.error('SPP 断开失败:', JSON.stringify(err)); - } - } - } - - // 发送数据 - sendData(hexString: string): void { - console.log('hexString',hexString) - if (this.clientNumber < 0) { - console.error('未连接 SPP'); - return; - } - console.info('发送数据:', 1111); - try { - const data = this.hexStringToArrayBuffer(hexString); - socket.sppWrite(this.clientNumber, data); - console.info('发送数据:', hexString); - } catch (err) { - console.error('发送数据失败:', JSON.stringify(err)); - } - } - - // 处理接收到的数据 - handleReceivedData(data: ArrayBuffer): void { - const hexString = this.arrayBufferToHexString(data); - console.info('接收数据:', hexString); - - // 解析 M9Z 协议响应 - this.parseM9zResponse(hexString); - } - - // 解析 M9Z 响应 - parseM9zResponse(hexString: string): void { - const bytes = this.hexStringToBytes(hexString); - - // 验证帧格式 - if (bytes.length < 5 || bytes[0] !== 0xEE || bytes[bytes.length - 1] !== 0xFF) { - console.warn('无效的响应帧'); - return; - } - - const instruction = bytes[1]; - const cmdType = bytes[2]; - - // 检查是否为响应帧 (D7=1) - if ((instruction & 0x80) === 0) { - console.warn('非响应帧'); - return; - } - - const realInstruction = instruction & 0x7F; - - // 根据指令码处理 - switch (realInstruction) { - case 0x02: // 设备时间 - if (bytes.length >= 9 && cmdType === 0x01) { - const timestamp = this.bytesToUint32LE(bytes.slice(5, 9)); - const date = new Date(timestamp * 1000); - this.deviceTime = this.formatDateTime(date); - } - break; - case 0x01: // 手动控制 - if (bytes.length >= 20 && cmdType === 0x01) { - this.parseManualControlResponse(bytes); - } - break; - } - } - - // 解析手动控制响应 - parseManualControlResponse(bytes: number[]): void { - const disable = bytes[5] === 0x01; - this.isManualMode = disable; - - const relayBits = bytes[7] | (bytes[8] << 8); - - for (let i = 0; i < 10; i++) { - this.loops[i].isOpen = ((relayBits >> i) & 0x01) === 1; - this.loops[i].pwm = bytes[10 + i] || 0; - } - - // 触发UI更新 - this.loops = [...this.loops]; - } - - // 获取设备时间 - getDeviceTime(): void { - // M9Z 读取设备时间命令: EE 02 01 00 36 FF - const command = this.buildM9zCommand(0x02, 0x01, 0x00, []); - this.sendData(command); - } - - // 获取手动控制信息 - getManualControlInfo(): void { - // M9Z 读取手动控制命令: EE 01 01 00 35 FF - const command = this.buildM9zCommand(0x01, 0x01, 0x00, []); - this.sendData(command); - } - - // 同步设备时间 - syncDeviceTime(): void { - const timestamp = Math.floor(Date.now() / 1000); - const data = this.uint32ToBytesLE(timestamp); - const command = this.buildM9zCommand(0x02, 0x02, 0x00, data); - this.sendData(command); - - this.showToastMessage('同步时间中...'); - - setTimeout(() => { - this.getDeviceTime(); - }, 500); - } - - // 设置手动控制 - setManualControl(): void { - const data: number[] = new Array(15).fill(0); - - // Byte0: 手动控制使能 - data[0] = this.isManualMode ? 0x01 : 0x00; - data[1] = 0x00; - - // Byte2-3: 继电器状态位图 - let relayBits = 0; - for (let i = 0; i < 10; i++) { - if (this.loops[i].isOpen) { - relayBits |= (1 << i); - } - } - data[2] = relayBits & 0xFF; - data[3] = (relayBits >> 8) & 0xFF; - data[4] = 0x00; - - // Byte5-14: PWM值 - for (let i = 0; i < 10; i++) { - data[5 + i] = this.loops[i].pwm; - } - - const command = this.buildM9zCommand(0x01, 0x02, 0x00, data); - this.sendData(command); - } - // 重启设备 (CMD: EE 7E 02 00 B3 FF) - restartDevice(): void { - const command = 'EE 7E 02 00 B3 FF'; - this.sendData(command); - this.showToastMessage('重启指令已发送'); - } - - // 构建 M9Z 命令 - buildM9zCommand(instruction: number, cmdType: number, index: number, data: number[]): string { - const payload: number[] = [instruction, cmdType, index]; - - if (data.length > 0) { - payload.push(data.length); - payload.push(...data); - } - - // 计算校验和 - let checksum = 0x33; - for (const byte of payload) { - checksum += byte; - } - checksum = checksum & 0xFF; - - // 构建完整帧 - const frame = [0xEE, ...payload, checksum, 0xFF]; - - return frame.map(b => b.toString(16).toUpperCase().padStart(2, '0')).join(''); - } - - // 切换回路状态 - toggleLoop(index: number): void { - if (!this.isManualMode) { - this.showToastMessage('请先切换到手动模式'); - return; - } - - this.loops[index].isOpen = !this.loops[index].isOpen; - this.loops = [...this.loops]; - this.setManualControl(); - } - - // 打开亮度调节 - openBrightnessDialog(index: number): void { - if (!this.isManualMode) { - this.showToastMessage('请先切换到手动模式'); - return; - } - - this.selectedLoopIndex = index; - this.currentBrightness = index >= 0 ? this.loops[index].pwm : 50; - this.showBrightnessDialog = true; - } - - // 确认亮度设置 - confirmBrightness(): void { - if (this.selectedLoopIndex >= 0) { - this.loops[this.selectedLoopIndex].pwm = this.currentBrightness; + if (this.deviceId) { + this.connectDevice(); } else { - // 全局设置 - for (let i = 0; i < 10; i++) { - this.loops[i].pwm = this.currentBrightness; - } + this.statusMessage = '无效的设备ID'; } - - this.loops = [...this.loops]; - this.showBrightnessDialog = false; - this.setManualControl(); } - - // 全开 - turnOnAll(): void { - if (!this.isManualMode) { - this.showToastMessage('请先切换到手动模式'); - return; - } - - for (let i = 0; i < 10; i++) { - this.loops[i].isOpen = true; - } - this.loops = [...this.loops]; - this.setManualControl(); - } - - // 全关 - turnOffAll(): void { - if (!this.isManualMode) { - this.showToastMessage('请先切换到手动模式'); - return; - } - - for (let i = 0; i < 10; i++) { - this.loops[i].isOpen = false; - } - this.loops = [...this.loops]; - this.setManualControl(); - } - - // 切换模式 - toggleMode(): void { - this.isManualMode = !this.isManualMode; - this.setManualControl(); - } - - // 刷新数据 - refresh(): void { - this.getDeviceTime(); - this.getManualControlInfo(); - this.showToastMessage('刷新中...'); - } - - // 返回上一页 - goBack(): void { - this.showBackDialog = true; - } - - // 执行返回 - doGoBack(): void { - this.disconnectSpp(); - router.back(); - } - - // 工具方法 - hexStringToArrayBuffer(hexString: string): ArrayBuffer { - const cleanHex = hexString.replace(/\s+/g, ''); - const bytes = new Uint8Array(cleanHex.length / 2); - for (let i = 0; i < cleanHex.length; i += 2) { - bytes[i / 2] = parseInt(cleanHex.substr(i, 2), 16); - } - return bytes.buffer; - } - - arrayBufferToHexString(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - return Array.from(bytes).map(b => b.toString(16).toUpperCase().padStart(2, '0')).join(' '); - } - - hexStringToBytes(hexString: string): number[] { - const cleanHex = hexString.replace(/\s+/g, ''); - const bytes: number[] = []; - for (let i = 0; i < cleanHex.length; i += 2) { - bytes.push(parseInt(cleanHex.substr(i, 2), 16)); - } - return bytes; - } - - bytesToUint32LE(bytes: number[]): number { - return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24); - } - - uint32ToBytesLE(value: number): number[] { - return [ - value & 0xFF, - (value >> 8) & 0xFF, - (value >> 16) & 0xFF, - (value >> 24) & 0xFF - ]; - } - - formatDateTime(date: Date): string { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - const h = String(date.getHours()).padStart(2, '0'); - const min = String(date.getMinutes()).padStart(2, '0'); - return `${y}-${m}-${d} ${h}:${min}`; + + aboutToDisappear() { + this.m9zService.disconnect(); } - // loading遮罩层 - @Builder - LoadingOverlay() { - if (this.dataLoading.deviceInfo || this.dataLoading.settingDeviceInfo) { - Stack() { - Rect() - .width('100%') - .height('100%') - .fill('rgba(11, 24, 48, 0.8)') - - Column() { - LoadingProgress() - .width(60) - .height(60) - .color('#22fdc8') - Text('数据加载中...') - .fontSize(14) - .fontColor('#eaf6fc') - .margin({ top: 10 }) - } + connectDevice() { + this.statusMessage = '正在连接...'; + this.m9zService.connect(this.deviceId, + () => { + this.isConnected = true; + this.statusMessage = '已连接'; + // 同步时间作为连接成功的标志性动作 + this.m9zService.getDeviceTime({ onSuccess:()=>{}, onError:()=>{} }); + }, + (err: string) => { + this.isConnected = false; + this.statusMessage = '连接失败: ' + err; } - .width('100%') - .height('100%') - .position({ x: 0, y: 0 }) - .zIndex(999) - } + ); } - // 构建手动控制页面 - @Builder - ManualControlContent() { - Stack() { - Column() { - // 设备信息 - Column() { - Text(`设备地址:${this.deviceId || 'N/A'}`) - .fontSize(12) - .fontColor('#b2ebf2') - .margin({ bottom: 4 }) - Text(`设备名称:${this.deviceName}`) - .fontSize(12) - .fontColor('#b2ebf2') - .margin({ bottom: 4 }) - Row() { - Text(`设备时间:${this.deviceTime}`) - .fontSize(12) - .fontColor('#eaf6fc') - if (this.dataLoading.deviceTime) { - LoadingProgress() - .width(12) - .height(12) - .color('#22fdc8') - .margin({ left: 4 }) - } - Blank() - Text(this.isConnected ? '已连接' : '未连接') - .fontSize(10) - .fontColor(this.isConnected ? '#4caf50' : '#ff5050') - } - .width('100%') - } - .width('100%') - .padding(10) - .backgroundColor('rgba(15, 30, 60, 0.4)') - .borderRadius(8) - .alignItems(HorizontalAlign.Start) - .margin({ bottom: 15 }) - - // 控制模式 - Row() { - Text('控制模式') - .fontSize(14) - .fontColor('#22fdc8') - Blank() - Button(this.isManualMode ? '手动模式' : '自动模式') - .fontSize(12) - .fontColor(this.isManualMode ? '#22fdc8' : '#b2ebf2') - .backgroundColor(this.isManualMode ? 'rgba(34, 253, 200, 0.1)' : 'rgba(20, 38, 81, 0.95)') - .border({ width: 1, color: this.isManualMode ? '#22fdc8' : '#213b67' }) - .borderRadius(4) - .padding({ left: 15, right: 15, top: 8, bottom: 8 }) - .onClick(() => this.toggleMode()) - } - .width('100%') - .margin({ bottom: 15 }) - - // 回路控制 - Row() { - Text('回路控制') - .fontSize(14) - .fontColor('#22fdc8') - if (this.dataLoading.deviceInfo) { - LoadingProgress() - .width(16) - .height(16) - .color('#22fdc8') - .margin({ left: 8 }) - } - } - .width('100%') - .margin({ bottom: 10 }) - - Grid() { - ForEach(this.loops, (loop: LoopInfo, index: number) => { - GridItem() { - Column() { - Row() { - Text('💡') - .fontSize(20) - } - .width(40) - .height(40) - .borderRadius(20) - .backgroundColor(loop.isOpen ? '#22fdc8' : '#2d3a51') - .border({ width: 1, color: loop.isOpen ? '#22fdc8' : '#3e7ba1' }) - .justifyContent(FlexAlign.Center) - - Text(`回路${index + 1}`) - .fontSize(12) - .fontColor('#eaf6fc') - .margin({ top: 4 }) - Text(loop.isOpen ? '开启' : '关闭') - .fontSize(10) - .fontColor('#b2ebf2') - } - .padding(8) - .onClick(() => this.toggleLoop(index)) - } - }) - } - .columnsTemplate('1fr 1fr 1fr 1fr 1fr') - .rowsGap(10) - .columnsGap(10) - .width('100%') - .margin({ bottom: 15 }) - - // 调光控制 - Text('调光控制') - .fontSize(14) - .fontColor('#22fdc8') - .width('100%') - .margin({ bottom: 10 }) - - Grid() { - ForEach(this.loops, (loop: LoopInfo, index: number) => { - GridItem() { - Column() { - Row() { - Text(`${loop.pwm}%`) - .fontSize(12) - .fontColor('#eaf6fc') - .fontWeight(FontWeight.Medium) - } - .width(50) - .height(50) - .borderRadius(25) - .backgroundColor(loop.pwm > 0 ? 'rgba(34, 253, 200, 0.2)' : '#2d3a51') - .border({ width: 1, color: loop.pwm > 0 ? '#22fdc8' : '#3e7ba1' }) - .justifyContent(FlexAlign.Center) - - Text(`通道${index + 1}`) - .fontSize(10) - .fontColor('#b2ebf2') - .margin({ top: 4 }) - } - .onClick(() => this.openBrightnessDialog(index)) - } + build() { + Column() { + // 顶部导航栏 + Row() { + // 使用 Text 代替可能的缺失资源 Image + Text('<') + .fontSize(24) + .fontColor('#fff') + .margin({ right: 16 }) + .onClick(() => { + router.back(); }) - } - .columnsTemplate('1fr 1fr 1fr 1fr 1fr') - .rowsGap(10) - .columnsGap(10) - .width('100%') - .margin({ bottom: 15 }) - // 快捷操作 - Row() { - Button('全开') - .fontSize(12) - .fontColor('#22fdc8') - .backgroundColor('rgba(34, 253, 200, 0.1)') - .border({ width: 1, color: '#22fdc8' }) - .borderRadius(4) - .layoutWeight(1) - .onClick(() => this.turnOnAll()) - - Button('全关') - .fontSize(12) - .fontColor('#fa5050') - .backgroundColor('rgba(250, 80, 80, 0.1)') - .border({ width: 1, color: '#fa5050' }) - .borderRadius(4) - .layoutWeight(1) - .margin({ left: 8 }) - .onClick(() => this.turnOffAll()) - - Button('一键调光') + Column() { + Text(this.deviceName) + .fontSize(20) + .fontWeight(FontWeight.Bold) + .fontColor('#eaf6fc') + Text(this.statusMessage) .fontSize(12) - .fontColor('#ffcb4d') - .backgroundColor('rgba(255, 203, 77, 0.1)') - .border({ width: 1, color: '#ffcb4d' }) - .borderRadius(4) - .layoutWeight(1) - .margin({ left: 8 }) - .onClick(() => this.openBrightnessDialog(-1)) + .fontColor(this.isConnected ? '#22fdc8' : '#fa5050') } - .width('100%') - .margin({ bottom: 10 }) + .alignItems(HorizontalAlign.Start) - Row() { - Button(this.isLoading ? '同步中...' : '同步时间') - .fontSize(12) - .fontColor('#40a7fa') - .backgroundColor('rgba(64, 167, 250, 0.1)') - .border({ width: 1, color: '#40a7fa' }) - .borderRadius(4) - .layoutWeight(1) - .onClick(() => this.syncDeviceTime()) - - Button('刷新数据') - .fontSize(12) - .fontColor('#b2ebf2') - .backgroundColor('rgba(20, 38, 81, 0.95)') - .border({ width: 1, color: '#213b67' }) - .borderRadius(4) - .layoutWeight(1) - .margin({ left: 8 }) - .onClick(() => this.refresh()) - - Button('重启') - .fontSize(12) - .fontColor('#ff5050') - .backgroundColor('rgba(250, 80, 80, 0.1)') - .border({ width: 1, color: '#ff5050' }) - .borderRadius(4) - .layoutWeight(1) - .margin({ left: 8 }) - .onClick(() => this.restartDevice()) - } - .width('100%') + Blank() - // 日出日落时间 - Row() { - Text(`日出时间:${this.sunriseTime}`) - .fontSize(12) - .fontColor('#b2ebf2') - if (this.dataLoading.sunRiseTime) { - LoadingProgress() - .width(12) - .height(12) - .color('#22fdc8') - .margin({ left: 4 }) - } - Blank() - Text(`日落时间:${this.sunsetTime}`) + // 重连按钮 + if (!this.isConnected) { + Button('重连') .fontSize(12) - .fontColor('#b2ebf2') + .height(24) + .onClick(() => this.connectDevice()) } - .width('100%') - .padding(10) - .backgroundColor('rgba(15, 30, 60, 0.4)') - .borderRadius(8) - .margin({ top: 15 }) } .width('100%') - - this.LoadingOverlay() - } - } - - // 构建参数设置页面 - @Builder - ParameterSettingsContent() { - Column() { - Text('参数设置') - .fontSize(16) - .fontColor('#22fdc8') - .margin({ bottom: 20 }) - - Text('功能开发中...') - .fontSize(14) - .fontColor('#8a9ba8') - } - .width('100%') - .padding(20) - .justifyContent(FlexAlign.Center) - } - - // 构建自动模式页面 - @Builder - AutoModeContent() { - Column() { - Text('自动模式') - .fontSize(16) - .fontColor('#22fdc8') - .margin({ bottom: 20 }) - - Text('功能开发中...') - .fontSize(14) - .fontColor('#8a9ba8') - } - .width('100%') - .padding(20) - .justifyContent(FlexAlign.Center) - } - - // 构建报警浏览页面 - @Builder - AlarmBrowserContent() { - Column() { - Text('报警浏览') - .fontSize(16) - .fontColor('#22fdc8') - .margin({ bottom: 20 }) - - Text('功能开发中...') - .fontSize(14) - .fontColor('#8a9ba8') - } - .width('100%') - .padding(20) - .justifyContent(FlexAlign.Center) - } - - build() { - Stack() { - Column() { - // Tab栏 - Row() { - ForEach(this.tabs, (tab: TabItem) => { - Column() { - Text(tab.name) - .fontSize(14) - .fontColor(this.activeTab === tab.index ? '#22fdc8' : '#b2ebf2') - .fontWeight(this.activeTab === tab.index ? FontWeight.Medium : FontWeight.Normal) - - if (this.activeTab === tab.index) { - Divider() - .strokeWidth(2) - .color('#22fdc8') - .width('100%') - .margin({ top: 8 }) - } - } - .layoutWeight(1) - .padding({ top: 12, bottom: 12 }) - .backgroundColor(this.activeTab === tab.index ? 'rgba(34, 253, 200, 0.1)' : 'transparent') - .onClick(() => { - this.activeTab = tab.index; - }) - }) - } - .width('100%') - .backgroundColor('rgba(20, 38, 81, 0.95)') - .border({ width: { bottom: 1 }, color: '#213b67' }) - - // 内容区域 - Scroll() { - Column() { - if (this.activeTab === 0) { - this.ManualControlContent() - } else if (this.activeTab === 1) { - this.ParameterSettingsContent() - } else if (this.activeTab === 2) { - this.AutoModeContent() - } else if (this.activeTab === 3) { - this.AlarmBrowserContent() - } + .padding({ left: 16, right: 16, top: 12, bottom: 12 }) + .backgroundColor('#142651') + + // 内容区域 (Tabs) + if (this.isConnected) { + Tabs({ barPosition: BarPosition.Start, controller: this.controller }) { + TabContent() { + ManualControl({ deviceId: this.deviceId }) } - .padding(10) - } - .layoutWeight(1) - .scrollBar(BarState.Off) - } - .width('100%') - .height('100%') - .linearGradient({ - direction: GradientDirection.RightBottom, - colors: [['#0b1830', 0], ['#1d3553', 1]] - }) - - // Toast 提示 - if (this.showToast) { - Text(this.toastMessage) - .fontSize(14) - .fontColor('#ffffff') - .backgroundColor('rgba(0, 0, 0, 0.7)') - .padding({ left: 20, right: 20, top: 10, bottom: 10 }) - .borderRadius(20) - .position({ x: '50%', y: '80%' }) - .translate({ x: '-50%' }) - } - - // 亮度调节弹窗 - if (this.showBrightnessDialog) { - Column() { - Column() { - Text('亮度调节') - .fontSize(16) - .fontColor('#22fdc8') - .fontWeight(FontWeight.Medium) - .margin({ bottom: 20 }) - - Text(`${this.currentBrightness}%`) - .fontSize(32) - .fontColor('#eaf6fc') - .fontWeight(FontWeight.Bold) - .margin({ bottom: 15 }) - - Slider({ - value: this.currentBrightness, - min: 0, - max: 100, - step: 1 - }) - .trackColor('#2d3a51') - .selectedColor('#22fdc8') - .blockColor('#22fdc8') - .width('100%') - .onChange((value: number) => { - this.currentBrightness = value; - }) - - Row() { - Button('取消') - .fontSize(14) - .fontColor('#b2ebf2') - .backgroundColor('rgba(20, 38, 81, 0.95)') - .border({ width: 1, color: '#213b67' }) - .borderRadius(4) - .layoutWeight(1) - .onClick(() => { - this.showBrightnessDialog = false; - }) - - Button('确认') - .fontSize(14) - .fontColor('#22fdc8') - .backgroundColor('rgba(34, 253, 200, 0.1)') - .border({ width: 1, color: '#22fdc8' }) - .borderRadius(4) - .layoutWeight(1) - .margin({ left: 10 }) - .onClick(() => this.confirmBrightness()) - } - .width('100%') - .margin({ top: 20 }) + .tabBar(this.TabBuilder(0, '手动控制')) + + TabContent() { + ParameterSettings({ deviceId: this.deviceId }) } - .width('80%') - .padding(20) - .backgroundColor('rgba(20, 38, 81, 0.98)') - .borderRadius(12) - .border({ width: 1, color: '#213b67' }) - } - .width('100%') - .height('100%') - .backgroundColor('rgba(0, 0, 0, 0.5)') - .justifyContent(FlexAlign.Center) - .onClick(() => { - this.showBrightnessDialog = false; - }) - } - - // 返回确认对话框 - if (this.showBackDialog) { - Column() { - Column() { - Text('提示') - .fontSize(18) - .fontWeight(FontWeight.Bold) - .fontColor('#eaf6fc') - .margin({ bottom: 15 }) - - Text('确定要断开蓝牙设备连接吗?') - .fontSize(14) - .fontColor('#b2ebf2') - .margin({ bottom: 20 }) - - Row() { - Button('取消') - .fontSize(14) - .fontColor('#999999') - .backgroundColor('rgba(255, 255, 255, 0.1)') - .borderRadius(4) - .layoutWeight(1) - .onClick(() => { - this.showBackDialog = false; - }) - - Button('断开') - .fontSize(14) - .fontColor('#ff5050') - .backgroundColor('rgba(255, 80, 80, 0.2)') - .borderRadius(4) - .layoutWeight(1) - .margin({ left: 15 }) - .onClick(() => { - this.showBackDialog = false; - this.doGoBack(); - }) - } - .width('100%') + .tabBar(this.TabBuilder(1, '参数设置')) + + TabContent() { + AutoMode({ deviceId: this.deviceId }) } - .width('80%') - .padding(20) - .backgroundColor('rgba(20, 38, 81, 0.98)') - .borderRadius(12) - .border({ width: 1, color: '#213b67' }) + .tabBar(this.TabBuilder(2, '自动模式')) + + TabContent() { + AlarmBrowser({ deviceId: this.deviceId }) + } + .tabBar(this.TabBuilder(3, '告警信息')) } - .width('100%') - .height('100%') - .backgroundColor('rgba(0, 0, 0, 0.5)') - .justifyContent(FlexAlign.Center) + .vertical(false) + .barMode(BarMode.Fixed) + .barWidth('100%') + .barHeight(56) + .animationDuration(200) + .backgroundColor('#0f1e3c') + .onChange((index: number) => { + this.currentIndex = index; + }) + } else { + // 连接失败/显示状态 + Column() { + Text(this.statusMessage) + .fontSize(16) + .fontColor('#ccc') + .margin({top: 100}) + } + .width('100%') + .height('100%') } } .width('100%') .height('100%') + .backgroundColor('#0a1629') // 深蓝色背景 + } + + @Builder TabBuilder(index: number, name: string) { + Column() { + Text(name) + .fontColor(this.currentIndex === index ? '#22fdc8' : '#666') + .fontSize(14) + .fontWeight(this.currentIndex === index ? FontWeight.Medium : FontWeight.Normal) + .lineHeight(20) + Divider() + .strokeWidth(2) + .color('#22fdc8') + .opacity(this.currentIndex === index ? 1 : 0) + .width('40%') + .margin({ top: 4 }) + }.width('100%') } } diff --git a/entry/src/main/ets/pages/components/AlarmBrowser.ets b/entry/src/main/ets/pages/components/AlarmBrowser.ets new file mode 100644 index 0000000..0656f43 --- /dev/null +++ b/entry/src/main/ets/pages/components/AlarmBrowser.ets @@ -0,0 +1,44 @@ +@Component +export struct AlarmBrowser { + @Prop deviceId: string; + @State alarms: string[] = []; + + aboutToAppear() { + // Bluetooth protocol does not support historical alarm retrieval. + // We could potentially read current status, but simply showing a notice is safer for now. + } + + build() { + Column() { + Text('告警信息') + .fontSize(16) + .fontColor('#22fdc8') + .width('100%') + .margin({ top: 20, bottom: 10 }) + + List() { + if (this.alarms.length === 0) { + ListItem() { + Text('暂无告警信息 (蓝牙模式仅支持查看部分实时状态)') + .fontColor('#999') + .fontSize(12) + .width('100%') + .textAlign(TextAlign.Center) + .padding(20) + } + } else { + ForEach(this.alarms, (alarm: string) => { + ListItem() { + Text(alarm).fontColor('red') + } + }) + } + } + .width('100%') + .height('100%') + } + .padding(20) + .width('100%') + .height('100%') + } +} diff --git a/entry/src/main/ets/pages/components/AutoMode.ets b/entry/src/main/ets/pages/components/AutoMode.ets new file mode 100644 index 0000000..8086302 --- /dev/null +++ b/entry/src/main/ets/pages/components/AutoMode.ets @@ -0,0 +1,522 @@ +import { M9zService, TaskProgram, TaskInstruction, M9zCmd, ControlTask, TaskLoop } from '../../utils/M9zService'; +import { promptAction } from '@kit.ArkUI'; + +@Observed +export class AutoModeHelper { + // Helper to avoid Observed restrictions if any +} + +@Component +export struct AutoMode { + @Prop deviceId: string; + @State currentMode: number = 0; // Device running mode + @State selectedViewMode: number = 0; // Tab selection (Mode 1/2/3) + + @State startTask: ControlTask = this.createEmptyTask(); + @State stopTask: ControlTask = this.createEmptyTask(); + @State middleTasks: ControlTask[] = []; + + @State isLoading: boolean = false; + @State isSetting: boolean = false; + + // Dialog state + @State showTaskDialog: boolean = false; + @State editingTaskType: string = ''; // 'start', 'stop', 'middle' + @State editingTaskIndex: number = -1; + @State editingTask: ControlTask = this.createEmptyTask(); + @State editingLoops: number[] = []; // selected loop indices + + private m9zService: M9zService = M9zService.getInstance(); + + aboutToAppear() { + if (this.deviceId) { + this.refreshData(); + } + } + + createEmptyTask(): ControlTask { + const loops: TaskLoop[] = []; + for(let i: number = 0; i < 10; i++) { + const loop: TaskLoop = new TaskLoop(); + loop.index = i; + loop.turnOn = false; + loop.pwm = 0; + loops.push(loop); + } + const task: ControlTask = new ControlTask(); + task.execTime = '00:00'; + task.timeType = 0; + task.loops = loops; + return task; + } + + refreshData() { + this.isLoading = true; + // Get Mode + this.m9zService.getDeviceMode({ + onSuccess: (mode: number): void => { + this.currentMode = mode; + }, + onError: (err: string): void => { + console.error('Get mode failed', err); + } + }); + + // Get Tasks for selected view mode (0,1,2 in protocol means Mode 1,2,3) + this.getTasksForMode(this.selectedViewMode); + } + + getTasksForMode(modeIndex: number) { + this.isLoading = true; + + // Start Task + this.m9zService.getTask(M9zCmd.StartTask, modeIndex, { + onSuccess: (prog: TaskProgram): void => { + const t: ControlTask | null = this.decompileTask(prog); + this.startTask = t ? t : this.createEmptyTask(); + }, + onError: (err: string): void => {} + }); + + // Stop Task + this.m9zService.getTask(M9zCmd.StopTask, modeIndex, { + onSuccess: (prog: TaskProgram): void => { + const t: ControlTask | null = this.decompileTask(prog); + this.stopTask = t ? t : this.createEmptyTask(); + }, + onError: (err: string): void => {} + }); + + // Middle Task + this.m9zService.getTask(M9zCmd.MiddleTask, modeIndex, { + onSuccess: (prog: TaskProgram): void => { + this.middleTasks = this.decompileMiddleTask(prog); + this.isLoading = false; + }, + onError: (err: string): void => { + this.isLoading = false; + } + }); + } + + changeViewMode(index: number) { + this.selectedViewMode = index; + this.refreshData(); + } + + changeDeviceMode() { + // Set device mode to current view mode + this.m9zService.setDeviceMode(this.selectedViewMode, { + onSuccess: (val: boolean): void => { + this.currentMode = this.selectedViewMode; + promptAction.showToast({ message: '模式切换成功' }); + }, + onError: (err: string): void => { + promptAction.showToast({ message: '切换失败: ' + err }); + } + }); + } + + // Compiler Logic + compileTask(task: ControlTask): TaskProgram { + const instructions: TaskInstruction[] = []; + + // 1. Time Instruction + const parts: string[] = task.execTime.split(':'); + if (parts.length >= 2) { + const h: number = Number(parts[0]); + const m: number = Number(parts[1]); + const minutes: number = h * 60 + m; + + const ins1: TaskInstruction = new TaskInstruction(); + ins1.type = 0x01; + ins1.param = task.timeType === 0 ? 0x01 : 0x02; + ins1.value = minutes; + instructions.push(ins1); + } + + // 2. Output Instructions + // Group by PWM + const pwmMap: Map = new Map(); + let openMask: number = 0; + let closeMask: number = 0; + + task.loops.forEach((loop: TaskLoop) => { + if (loop.turnOn) { + openMask |= (1 << loop.index); + } else { + closeMask |= (1 << loop.index); + } + + if (loop.pwm > 0) { + const list: number[] = pwmMap.get(loop.pwm) || []; + list.push(loop.index); + pwmMap.set(loop.pwm, list); + } + }); + + // PWM Instructions + pwmMap.forEach((indices: number[], pwm: number) => { + let mask: number = 0; + indices.forEach((idx: number) => { mask |= (1 << idx); }); + const ins: TaskInstruction = new TaskInstruction(); + ins.type = 0x04; + ins.param = 0x03; + ins.value = (mask << 12) | pwm; + instructions.push(ins); + }); + + // Relay Instructions + if (openMask > 0) { + const ins: TaskInstruction = new TaskInstruction(); + ins.type = 0x04; + ins.param = 0x01; + ins.value = (openMask << 12) | 0x01; + instructions.push(ins); + } + if (closeMask > 0) { + const ins: TaskInstruction = new TaskInstruction(); + ins.type = 0x04; + ins.param = 0x01; + ins.value = (closeMask << 12) | 0x00; + instructions.push(ins); + } + + const prog: TaskProgram = new TaskProgram(); + prog.index = this.selectedViewMode; + prog.instructions = instructions; + return prog; + } + + compileMiddleTasks(tasks: ControlTask[]): TaskProgram { + const allInstructions: TaskInstruction[] = []; + // Sort tasks by time? The user should ensure order, simplified here. + + tasks.forEach((task: ControlTask) => { + const prog: TaskProgram = this.compileTask(task); + prog.instructions.forEach((ins: TaskInstruction) => { allInstructions.push(ins); }); + }); + + // Add Loop/GOTO instruction at end + const gotoIns: TaskInstruction = new TaskInstruction(); + gotoIns.type = 0x02; // GOTO + gotoIns.param = 0x01; // Enable + gotoIns.value = 0; // Jump to 0, count 0 + allInstructions.push(gotoIns); + + const prog: TaskProgram = new TaskProgram(); + prog.index = this.selectedViewMode; + prog.instructions = allInstructions; + return prog; + } + + // Decompiler Logic + decompileTask(prog: TaskProgram): ControlTask | null { + if (!prog.instructions || prog.instructions.length === 0) { + return null; + } + + const task: ControlTask = this.createEmptyTask(); + let hasTime: boolean = false; + + for (const ins of prog.instructions) { + if (ins.type === 0x01) { // CMP + hasTime = true; + const minutes: number = ins.value; + const h: number = Math.floor(minutes / 60); + const m: number = minutes % 60; + task.execTime = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; + task.timeType = (ins.param === 0x01) ? 0 : 1; + } else if (ins.type === 0x04) { // OUTPUT + if (ins.param === 0x01) { // Relay + const isOpen: boolean = (ins.value & 1) === 1; + const mask: number = (ins.value >> 12) & 0xFFF; + for (let i: number = 0; i < 10; i++) { + if ((mask >> i) & 1) { + task.loops[i].turnOn = isOpen; + } + } + } else if (ins.param === 0x03) { // PWM + const pwm: number = ins.value & 0xFF; + const mask: number = (ins.value >> 12) & 0xFFF; + for (let i: number = 0; i < 10; i++) { + if ((mask >> i) & 1) { + task.loops[i].pwm = pwm; + } + } + } + } + } + return hasTime ? task : null; + } + + decompileMiddleTask(prog: TaskProgram): ControlTask[] { + const tasks: ControlTask[] = []; + let currentTask: ControlTask | null = null; + + for (const ins of prog.instructions) { + if (ins.type === 0x01) { // New Time instruction = New Task + if (currentTask) { + tasks.push(currentTask); + } + currentTask = this.createEmptyTask(); + + const minutes: number = ins.value; + const h: number = Math.floor(minutes / 60); + const m: number = minutes % 60; + currentTask.execTime = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; + + if (ins.param === 0x01 || ins.param === 0x05) { + currentTask.timeType = 0; + } else { + currentTask.timeType = 1; + } + } else if (ins.type === 0x04 && currentTask) { + if (ins.param === 0x01) { // Relay + const isOpen: boolean = (ins.value & 1) === 1; + const mask: number = (ins.value >> 12) & 0xFFF; + for (let i: number = 0; i < 10; i++) { + if ((mask >> i) & 1) { + currentTask.loops[i].turnOn = isOpen; + } + } + } else if (ins.param === 0x03) { // PWM + const pwm: number = ins.value & 0xFF; + const mask: number = (ins.value >> 12) & 0xFFF; + for (let i: number = 0; i < 10; i++) { + if ((mask >> i) & 1) { + currentTask.loops[i].pwm = pwm; + } + } + } + } + } + if (currentTask) { + tasks.push(currentTask); + } + return tasks; + } + + // --- UI Actions --- + + openEditDialog(type: string, index: number) { + if (type === 'start') { + // Deep copy + const t: ControlTask = this.startTask; + const copy: ControlTask = this.createEmptyTask(); + copy.execTime = t.execTime; + copy.timeType = t.timeType; + t.loops.forEach((l: TaskLoop, i: number) => { + copy.loops[i].turnOn = l.turnOn; + copy.loops[i].pwm = l.pwm; + }); + this.editingTask = copy; + } + else if (type === 'stop') { + const t: ControlTask = this.stopTask; + const copy: ControlTask = this.createEmptyTask(); + copy.execTime = t.execTime; + copy.timeType = t.timeType; + t.loops.forEach((l: TaskLoop, i: number) => { + copy.loops[i].turnOn = l.turnOn; + copy.loops[i].pwm = l.pwm; + }); + this.editingTask = copy; + } + else if (type === 'middle') { + const t: ControlTask = index >= 0 ? this.middleTasks[index] : this.createEmptyTask(); + const copy: ControlTask = this.createEmptyTask(); + copy.execTime = t.execTime; + copy.timeType = t.timeType; + t.loops.forEach((l: TaskLoop, i: number) => { + copy.loops[i].turnOn = l.turnOn; + copy.loops[i].pwm = l.pwm; + }); + this.editingTask = copy; + } + + this.editingTaskType = type; + this.editingTaskIndex = index; + + this.editingLoops = []; + this.editingTask.loops.forEach((l: TaskLoop) => { + if (l.turnOn) { + this.editingLoops.push(l.index); + } + }); + + this.showTaskDialog = true; + } + + toggleEditingLoop(index: number) { + const idx: number = this.editingLoops.indexOf(index); + if (idx >= 0) { + this.editingLoops.splice(idx, 1); + } else { + this.editingLoops.push(index); + } + } + + saveTask() { + // Apply editing loops + this.editingTask.loops.forEach((l: TaskLoop) => { + l.turnOn = this.editingLoops.includes(l.index); + }); + + if (this.editingTaskType === 'start') { + this.startTask = this.editingTask; + const prog: TaskProgram = this.compileTask(this.startTask); + this.m9zService.setTask(M9zCmd.StartTask, prog, { onSuccess:():void=>{}, onError:(e:string):void=>{} }); + } else if (this.editingTaskType === 'stop') { + this.stopTask = this.editingTask; + const prog: TaskProgram = this.compileTask(this.stopTask); + this.m9zService.setTask(M9zCmd.StopTask, prog, { onSuccess:():void=>{}, onError:(e:string):void=>{} }); + } else if (this.editingTaskType === 'middle') { + if (this.editingTaskIndex >= 0) { + this.middleTasks[this.editingTaskIndex] = this.editingTask; + } else { + this.middleTasks.push(this.editingTask); // Add new + } + const prog: TaskProgram = this.compileMiddleTasks(this.middleTasks); + this.m9zService.setTask(M9zCmd.MiddleTask, prog, { onSuccess:():void=>{}, onError:(e:string):void=>{} }); + } + + this.showTaskDialog = false; + promptAction.showToast({ message: '任务保存成功' }); + } + + deleteMiddleTask(index: number) { + this.middleTasks.splice(index, 1); + const prog: TaskProgram = this.compileMiddleTasks(this.middleTasks); + this.m9zService.setTask(M9zCmd.MiddleTask, prog, { + onSuccess:():void=>{ promptAction.showToast({ message: '删除成功' }); }, + onError:(err:string):void=> { promptAction.showToast({ message: '删除失败' }); } + }); + } + + build() { + Column() { + // Mode Tabs + Row() { + ForEach([0,1,2], (mode: number) => { + Button(`模式${mode+1}`) + .backgroundColor(this.selectedViewMode === mode ? 'rgba(34, 253, 200, 0.2)' : '#2d3a51') + .fontColor('#eaf6fc') + .onClick((): void => {this.changeViewMode(mode);}) + .layoutWeight(1) + }) + }.width('100%').margin({ bottom: 10 }) + + List() { + ListItem() { + this.TaskCard('开始任务', this.startTask, (): void => {this.openEditDialog('start', -1);}) + } + + ForEach(this.middleTasks, (task: ControlTask, index: number) => { + ListItem() { + this.TaskCard(`中间任务 ${index+1}`, task, (): void => {this.openEditDialog('middle', index);}, true, (): void => {this.deleteMiddleTask(index);}) + } + }) + + ListItem() { + Button('+ 添加中间任务') + .width('100%') + .backgroundColor('rgba(34, 253, 200, 0.1)') + .fontColor('#22fdc8') + .onClick((): void => {this.openEditDialog('middle', -1);}) // -1 for new + .margin({ top: 10, bottom: 10 }) + } + + ListItem() { + this.TaskCard('结束任务', this.stopTask, (): void => {this.openEditDialog('stop', -1);}) + } + } + .layoutWeight(1) + .width('100%') + + // Control Panel + Row() { + Button(`应用模式${this.selectedViewMode+1}到设备`) + .onClick((): void => {this.changeDeviceMode();}) + .width('80%') + .backgroundColor('#22fdc8') + .fontColor('#000') + } + .width('100%') + .justifyContent(FlexAlign.Center) + .padding(10) + + // Dialog + if(this.showTaskDialog) { + Stack() { + Rect().width('100%').height('100%').fill('rgba(0,0,0,0.8)').onClick((): void => {this.showTaskDialog = false;}) + Column() { + Text('编辑任务').fontColor('#fff').fontSize(16).margin({bottom: 10}) + + // Loops + Text('选择开启回路').fontColor('#b2ebf2').margin({bottom: 5}).alignSelf(ItemAlign.Start) + Grid() { + ForEach([0,1,2,3,4,5,6,7,8,9], (idx: number) => { + GridItem() { + Text(`${idx+1}`) + .backgroundColor(this.editingLoops.includes(idx) ? '#22fdc8' : '#333') + .fontColor(this.editingLoops.includes(idx) ? '#000' : '#fff') + .width(30).height(30).borderRadius(15) + .textAlign(TextAlign.Center) + .onClick((): void => {this.toggleEditingLoop(idx);}) + } + }) + }.columnsTemplate('1fr 1fr 1fr 1fr 1fr').height(80) + + // Time + Row() { + Text('时间').fontColor('#b2ebf2') + TextInput({ text: this.editingTask.execTime }).width(100).backgroundColor('#333').fontColor('#fff') + .onChange((val: string): void => {this.editingTask.execTime = val;}) + }.width('100%').margin({ top: 10 }).justifyContent(FlexAlign.SpaceBetween) + + // Buttons + Row() { + Button('取消').onClick((): void => {this.showTaskDialog = false;}).backgroundColor('#666') + Button('保存').onClick((): void => {this.saveTask();}).backgroundColor('#22fdc8').fontColor('#000') + }.width('100%').justifyContent(FlexAlign.SpaceAround).margin({ top: 20 }) + } + .backgroundColor('#1d3553') + .padding(20) + .width('90%') + .borderRadius(10) + }.position({x:0,y:0}).zIndex(100) + } + } + } + + @Builder + TaskCard(title: string, task: ControlTask, onEdit: () => void, isDeletable: boolean = false, onDelete?: () => void) { + Column() { + Row() { + Text(title).fontColor('#22fdc8').fontWeight(FontWeight.Bold) + Blank() + Text(task.execTime).fontColor('#fff') + }.width('100%').margin({ bottom: 5 }) + + Row() { + ForEach(task.loops, (loop: TaskLoop) => { + if (loop.turnOn) { + Text(`${loop.index+1}`).fontSize(10).fontColor('#22fdc8').margin({right: 2}) + } + }) + }.width('100%') + + Row() { + Button('编辑').fontSize(10).height(24).onClick(onEdit) + if (isDeletable && onDelete) { + Button('删除').fontSize(10).height(24).backgroundColor('red').margin({left: 10}).onClick(onDelete) + } + }.width('100%').justifyContent(FlexAlign.End).margin({ top: 5 }) + } + .backgroundColor('rgba(20, 38, 81, 0.95)') + .padding(10) + .margin({ bottom: 10 }) + .borderRadius(5) + } +} diff --git a/entry/src/main/ets/pages/components/ManualControl.ets b/entry/src/main/ets/pages/components/ManualControl.ets new file mode 100644 index 0000000..6534655 --- /dev/null +++ b/entry/src/main/ets/pages/components/ManualControl.ets @@ -0,0 +1,467 @@ +import { M9zService, ManualData, LoopInfo } from '../../utils/M9zService'; + +@Component +export struct ManualControl { + @Prop deviceId: string; + @State manualData: ManualData = new ManualData(); // 初始化 Class + @State deviceTime: string = '--:--:--'; + @State isConnected: boolean = false; + @State isLoading: boolean = false; + @State selectedLoopIndex: number = -1; + @State currentBrightness: number = 50; + @State showBrightnessDialog: boolean = false; + @State toastMessage: string = ''; + @State showToast: boolean = false; + + private m9zService: M9zService = M9zService.getInstance(); + private timer: number = -1; + + aboutToAppear() { + this.initLoops(); + if (this.deviceId) { + this.refreshData(); + this.startTimer(); + } + } + + aboutToDisappear() { + if (this.timer > 0) { + clearInterval(this.timer); + } + } + + startTimer() { + this.timer = setInterval((): void => { + this.m9zService.getDeviceTime({ + onSuccess: (date: Date): void => { + this.deviceTime = this.formatDateTime(date); + }, + onError: (err: string): void => { + // console.error('获取时间失败', err); + } + }); + }, 1000); + } + + initLoops() { + const loops: LoopInfo[] = []; + for (let i = 0; i < 10; i++) { + const loop = new LoopInfo(); + loop.index = i; + loop.name = `#${i + 1}`; + loop.isOpen = false; + loop.pwm = 0; + loops.push(loop); + } + const data = new ManualData(); + data.disable = false; + data.loops = loops; + this.manualData = data; + } + + refreshData() { + this.isLoading = true; + this.m9zService.getManualControlInfo({ + onSuccess: (data: ManualData): void => { + this.manualData = data; + this.isLoading = false; + }, + onError: (err: string): void => { + this.showToastMessage('获取数据失败: ' + err); + this.isLoading = false; + } + }); + } + + toggleMode() { + // Deep copy manualData + const newData = new ManualData(); + newData.disable = !this.manualData.disable; + this.manualData.loops.forEach((l: LoopInfo): void => { + const loop = new LoopInfo(); + loop.index = l.index; + loop.name = l.name; + loop.isOpen = l.isOpen; + loop.pwm = l.pwm; + newData.loops.push(loop); + }); + + this.m9zService.setManualControl(newData, { + onSuccess: (success: boolean): void => { + if (success) { + this.manualData = newData; // Re-assign triggers UI update + this.showToastMessage('模式切换成功'); + } + }, + onError: (err: string): void => { + this.showToastMessage('模式切换失败: ' + err); + } + }); + } + + toggleLoop(index: number) { + if (!this.manualData.disable) { // disable=true 代表手动模式 (0x01) + this.showToastMessage('请先切换到手动模式'); + return; + } + + const newData = new ManualData(); + newData.disable = this.manualData.disable; + this.manualData.loops.forEach((l: LoopInfo): void => { + const loop = new LoopInfo(); + loop.index = l.index; + loop.name = l.name; + loop.isOpen = l.isOpen; + loop.pwm = l.pwm; + newData.loops.push(loop); + }); + + // Toggle + newData.loops[index].isOpen = !newData.loops[index].isOpen; + + this.m9zService.setManualControl(newData, { + onSuccess: (success: boolean): void => { + if (success) { + this.manualData = newData; + } + }, + onError: (err: string): void => { + this.showToastMessage('控制失败: ' + err); + } + }); + } + + openBrightnessDialog(index: number) { + if (!this.manualData.disable) { + this.showToastMessage('请先切换到手动模式'); + return; + } + this.selectedLoopIndex = index; + if (index >= 0) { + this.currentBrightness = this.manualData.loops[index].pwm; + } else { + this.currentBrightness = 50; + } + this.showBrightnessDialog = true; + } + + confirmBrightness() { + const newData = new ManualData(); + newData.disable = this.manualData.disable; + this.manualData.loops.forEach((l: LoopInfo): void => { + const loop = new LoopInfo(); + loop.index = l.index; + loop.name = l.name; + loop.isOpen = l.isOpen; + loop.pwm = l.pwm; + newData.loops.push(loop); + }); + + if (this.selectedLoopIndex >= 0) { + newData.loops[this.selectedLoopIndex].pwm = this.currentBrightness; + } else { + // 全局调光 + for (let i = 0; i < 10; i++) { + newData.loops[i].pwm = this.currentBrightness; + } + } + + this.m9zService.setManualControl(newData, { + onSuccess: (success: boolean): void => { + if (success) { + this.manualData = newData; + this.showBrightnessDialog = false; + this.showToastMessage('调光成功'); + } + }, + onError: (err: string): void => { + this.showToastMessage('调光失败: ' + err); + } + }); + } + + turnOnAll(): void { + if (!this.manualData.disable) { + this.showToastMessage('请先切换到手动模式'); + return; + } + const newData = new ManualData(); + newData.disable = this.manualData.disable; + this.manualData.loops.forEach((l: LoopInfo): void => { + const loop = new LoopInfo(); + loop.index = l.index; + loop.name = l.name; + loop.isOpen = true; // ON + loop.pwm = l.pwm; + newData.loops.push(loop); + }); + this.sendControl(newData, '全开成功'); + } + + turnOffAll(): void { + if (!this.manualData.disable) { + this.showToastMessage('请先切换到手动模式'); + return; + } + const newData = new ManualData(); + newData.disable = this.manualData.disable; + this.manualData.loops.forEach((l: LoopInfo): void => { + const loop = new LoopInfo(); + loop.index = l.index; + loop.name = l.name; + loop.isOpen = false; // OFF + loop.pwm = l.pwm; + newData.loops.push(loop); + }); + this.sendControl(newData, '全关成功'); + } + + sendControl(newData: ManualData, successMsg: string): void { + this.m9zService.setManualControl(newData, { + onSuccess: (success: boolean): void => { + if (success) { + this.manualData = newData; + this.showToastMessage(successMsg); + } + }, + onError: (err: string): void => { + this.showToastMessage('操作失败: ' + err); + } + }); + } + + syncDeviceTime(): void { + this.m9zService.setDeviceTime(new Date(), { + onSuccess: (): void => this.showToastMessage('时间同步成功'), + onError: (err: string): void => this.showToastMessage('同步失败:' + err) + }); + } + + showToastMessage(msg: string): void { + this.toastMessage = msg; + this.showToast = true; + setTimeout((): void => { + this.showToast = false; + }, 2000); + } + + formatDateTime(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const h = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + const s = String(date.getSeconds()).padStart(2, '0'); + return `${y}-${m}-${d} ${h}:${min}:${s}`; + } + + build() { + Column() { + // 这里的UI结构参考 ManualControl.vue 和 BluetoothControlPage.ets 融合 + // 设备信息 + Column() { + Row() { + Text(`设备时间:${this.deviceTime}`) + .fontSize(14) + .fontColor('#eaf6fc') + Blank() + Button('刷新') + .fontSize(12) + .height(24) + .onClick(() => this.refreshData()) + }.width('100%').margin({bottom: 10}) + + // 模式切换 + Row() { + Text('控制模式') + .fontSize(14) + .fontColor('#22fdc8') + Blank() + Button(this.manualData.disable ? '手动模式' : '自动模式') + .fontSize(12) + .fontColor(this.manualData.disable ? '#22fdc8' : '#b2ebf2') + .backgroundColor(this.manualData.disable ? 'rgba(34, 253, 200, 0.1)' : 'rgba(20, 38, 81, 0.95)') + .border({ width: 1, color: this.manualData.disable ? '#22fdc8' : '#213b67' }) + .onClick((): void => { + this.toggleMode(); + }) + }.width('100%').margin({bottom: 15}) + + // 回路控制 Grid + Text('回路控制') + .fontSize(14) + .fontColor('#22fdc8') + .width('100%') + .margin({ bottom: 10 }) + + Grid() { + ForEach(this.manualData.loops, (loop: LoopInfo): void => { + GridItem() { + Column() { + Text('💡') + .fontSize(20) + .fontColor(loop.isOpen ? '#22fdc8' : '#666') + .backgroundColor(loop.isOpen ? 'rgba(34, 253, 200, 0.2)' : '#2d3a51') + .width(40).height(40) + .borderRadius(20) + .textAlign(TextAlign.Center) + .lineHeight(40) + .border({ width: 1, color: loop.isOpen ? '#22fdc8' : '#3e7ba1' }) + + Text(loop.name) + .fontSize(12) + .fontColor('#eaf6fc') + .margin({top: 5}) + } + .onClick((): void => { + this.toggleLoop(loop.index); + }) + } + }) + } + .columnsTemplate('1fr 1fr 1fr 1fr 1fr') + .rowsGap(15) + .height(140) + + // 调光控制 Grid + Text('调光控制') + .fontSize(14) + .fontColor('#22fdc8') + .width('100%') + .margin({ top: 15, bottom: 10 }) + + Grid() { + ForEach(this.manualData.loops, (loop: LoopInfo): void => { + GridItem() { + Column() { + Text(`${loop.pwm}%`) + .fontSize(12) + .fontColor('#eaf6fc') + .backgroundColor(loop.pwm > 0 ? 'rgba(34, 253, 200, 0.2)' : '#2d3a51') + .width(40).height(40) + .borderRadius(20) + .textAlign(TextAlign.Center) + .lineHeight(40) + .border({ width: 1, color: loop.pwm > 0 ? '#22fdc8' : '#3e7ba1' }) + + Text(`通道${loop.index + 1}`) + .fontSize(10) + .fontColor('#b2ebf2') + .margin({top: 5}) + } + .onClick((): void => { + this.openBrightnessDialog(loop.index); + }) + } + }) + } + .columnsTemplate('1fr 1fr 1fr 1fr 1fr') + .rowsGap(15) + .height(140) + + // 快捷操作 + Row() { + Button('全开') + .fontSize(12) + .fontColor('#22fdc8') + .backgroundColor('rgba(34, 253, 200, 0.1)') + .border({ width: 1, color: '#22fdc8' }) + .layoutWeight(1) + .onClick((): void => { + this.turnOnAll(); + }) + + Button('全关') + .fontSize(12) + .fontColor('#fa5050') + .backgroundColor('rgba(250, 80, 80, 0.1)') + .border({ width: 1, color: '#fa5050' }) + .layoutWeight(1) + .margin({ left: 8 }) + .onClick((): void => { + this.turnOffAll(); + }) + + Button('一键调光') + .fontSize(12) + .fontColor('#ffcb4d') + .backgroundColor('rgba(255, 203, 77, 0.1)') + .border({ width: 1, color: '#ffcb4d' }) + .layoutWeight(1) + .margin({ left: 8 }) + .onClick((): void => { + this.openBrightnessDialog(-1); + }) + } + .width('100%') + .margin({ top: 15 }) + + Row() { + Button('同步时间') + .fontSize(12) + .fontColor('#40a7fa') + .backgroundColor('rgba(64, 167, 250, 0.1)') + .border({ width: 1, color: '#40a7fa' }) + .layoutWeight(1) + .onClick((): void => { + this.syncDeviceTime(); + }) + }.width('100%').margin({top: 10}) + + } + .padding(15) + .width('100%') + + if (this.showToast) { + Text(this.toastMessage) + .fontSize(12) + .fontColor('white') + .backgroundColor('rgba(0,0,0,0.7)') + .padding(10) + .borderRadius(5) + .position({x: '35%', y: '80%'}) + } + + if (this.showBrightnessDialog) { + // 简易弹窗 + Stack() { + Rect().width('100%').height('100%').fill('rgba(0,0,0,0.5)').onClick((): void => { + this.showBrightnessDialog = false; + }) + Column() { + Text(this.selectedLoopIndex >= 0 ? `通道${this.selectedLoopIndex + 1} 亮度` : '全部通道 亮度') + .fontColor('#eaf6fc').margin({bottom: 20}) + + Text(`${this.currentBrightness}%`).fontColor('#22fdc8').fontSize(24).margin({bottom: 20}) + + Slider({ + value: this.currentBrightness, + min: 0, + max: 100, + style: SliderStyle.OutSet + }) + .onChange((value: number): void => { + this.currentBrightness = value; + }) + .width('80%') + + Row() { + Button('取消').onClick((): void => {this.showBrightnessDialog = false;}).backgroundColor('transparent').fontColor('#999') + Button('确定').onClick((): void => {this.confirmBrightness();}).backgroundColor('#22fdc8').fontColor('#000') + }.margin({top: 20}).width('100%').justifyContent(FlexAlign.SpaceAround) + } + .width('80%') + .backgroundColor('#1d3553') + .padding(20) + .borderRadius(10) + } + .position({x: 0, y: 0}) + .zIndex(100) + .width('100%') + .height('100%') + } + } + .width('100%') + .height('100%') + } +} diff --git a/entry/src/main/ets/pages/components/ParameterSettings.ets b/entry/src/main/ets/pages/components/ParameterSettings.ets new file mode 100644 index 0000000..f5d5e2e --- /dev/null +++ b/entry/src/main/ets/pages/components/ParameterSettings.ets @@ -0,0 +1,203 @@ +import { M9zService, LgnLatData, SunRiseSetData, SunRiseTimeData } from '../../utils/M9zService'; +import { promptAction } from '@kit.ArkUI'; + +@Component +export struct ParameterSettings { + @Prop deviceId: string; + @State isLoading: boolean = false; + @State lgnLat: LgnLatData = new LgnLatData(); + @State sunRiseSet: SunRiseSetData = new SunRiseSetData(); + @State sunRiseTime: SunRiseTimeData = new SunRiseTimeData(); + + private m9zService: M9zService = M9zService.getInstance(); + + aboutToAppear() { + if (this.deviceId) { + this.refreshData(); + } + } + + refreshData() { + this.isLoading = true; + + // Get LgnLat + this.m9zService.getLgnLat({ + onSuccess: (data: LgnLatData): void => { + this.lgnLat = data; + }, + onError: (err: string): void => { + // console.error('Get lgnlat failed', err); + } + }); + + // Get SunRiseSet + this.m9zService.getSunRiseSet({ + onSuccess: (data: SunRiseSetData): void => { + this.sunRiseSet = data; + }, + onError: (err: string): void => {} + }); + + // Get SunRiseTime + this.m9zService.getSunRiseTime({ + onSuccess: (data: SunRiseTimeData): void => { + this.sunRiseTime = data; + this.isLoading = false; + }, + onError: (err: string): void => { + this.isLoading = false; + } + }); + } + + syncLocation() { + // 模拟同步定位,实际应调用定位API + const newData = new LgnLatData(); + newData.lgn = 113.123456; + newData.lat = 23.123456; + newData.loc = '广东省广州市'; + + this.m9zService.setLgnLat(newData, { + onSuccess: (success: boolean): void => { + this.lgnLat = newData; + promptAction.showToast({ message: '同步成功' }); + }, + onError: (err: string): void => { + promptAction.showToast({ message: '同步失败:' + err }); + } + }); + } + + setLgnLat() { + this.m9zService.setLgnLat(this.lgnLat, { + onSuccess: (success: boolean): void => { + promptAction.showToast({ message: '设置经纬度成功' }); + }, + onError: (err: string): void => { + promptAction.showToast({ message: '设置失败:' + err }); + } + }); + } + + setSunRiseSet() { + this.m9zService.setSunRiseSet(this.sunRiseSet, { + onSuccess: (success: boolean): void => { + promptAction.showToast({ message: '设置偏差成功' }); + }, + onError: (err: string): void => { + promptAction.showToast({ message: '设置失败:' + err }); + } + }); + } + + restartDevice() { + AlertDialog.show({ + title: '确认', + message: '确定要重启设备吗?', + primaryButton: { + value: '取消', + action: (): void => {} + }, + secondaryButton: { + value: '重启', + action: (): void => { + this.m9zService.restartDevice({ + onSuccess: (val: boolean): void => { promptAction.showToast({ message: '发送重启命令成功' }); }, + onError: (err: string): void => { promptAction.showToast({ message: '失败:' + err }); } + }); + } + } + }); + } + + build() { + Column() { + // 经纬度设置 + Column() { + Text('经纬度设置').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#22fdc8').width('100%').margin({bottom: 10}) + + Row() { + Text('经度: ').fontColor('#eaf6fc') + TextInput({ text: this.lgnLat.lgn.toString() }) + .width(100) + .onChange((val) => { this.lgnLat.lgn = Number(val); }) + .fontColor('#fff').backgroundColor('#333') + }.width('100%').margin({bottom: 5}) + + Row() { + Text('纬度: ').fontColor('#eaf6fc') + TextInput({ text: this.lgnLat.lat.toString() }) + .width(100) + .onChange((val) => { this.lgnLat.lat = Number(val); }) + .fontColor('#fff').backgroundColor('#333') + }.width('100%').margin({bottom: 10}) + + Row() { + Button('同步手机定位').fontSize(12).onClick(() => this.syncLocation()).margin({right: 10}) + Button('保存').fontSize(12).onClick(() => this.setLgnLat()) + } + } + .padding(10) + .backgroundColor('rgba(20, 38, 81, 0.95)') + .borderRadius(8) + .margin({bottom: 15}) + .width('100%') + + // 日出日落偏差 + Column() { + Text('日出日落偏差 (分钟)').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#22fdc8').width('100%').margin({bottom: 10}) + + Row() { + Text('日出偏差: ').fontColor('#eaf6fc') + TextInput({ text: this.sunRiseSet.rise.toString() }) + .width(80) + .onChange((val) => { this.sunRiseSet.rise = Number(val); }) + .fontColor('#fff').backgroundColor('#333') + }.width('100%').margin({bottom: 5}) + + Row() { + Text('日落偏差: ').fontColor('#eaf6fc') + TextInput({ text: this.sunRiseSet.set.toString() }) + .width(80) + .onChange((val) => { this.sunRiseSet.set = Number(val); }) + .fontColor('#fff').backgroundColor('#333') + }.width('100%').margin({bottom: 10}) + + Row() { + Button('保存').fontSize(12).onClick(() => this.setSunRiseSet()) + } + } + .padding(10) + .backgroundColor('rgba(20, 38, 81, 0.95)') + .borderRadius(8) + .margin({bottom: 15}) + .width('100%') + + // 当前日出日落时间 + Column() { + Text('今日光照时间').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#22fdc8').width('100%').margin({bottom: 10}) + Text(`日出: ${this.sunRiseTime.sunrise ? this.formatTime(this.sunRiseTime.sunrise) : '--:--'}`).fontColor('#eaf6fc') + Text(`日落: ${this.sunRiseTime.sunset ? this.formatTime(this.sunRiseTime.sunset) : '--:--'}`).fontColor('#eaf6fc') + } + .padding(10) + .backgroundColor('rgba(20, 38, 81, 0.95)') + .borderRadius(8) + .margin({bottom: 15}) + .width('100%') + + // 重启 + Button('重启设备') + .backgroundColor('#fa5050') + .width('80%') + .margin({top: 20}) + .onClick(() => this.restartDevice()) + + }.width('100%').padding(10) + } + + formatTime(date: Date): string { + const h = String(date.getHours()).padStart(2, '0'); + const m = String(date.getMinutes()).padStart(2, '0'); + return `${h}:${m}`; + } +} diff --git a/entry/src/main/ets/utils/M9zService.ets b/entry/src/main/ets/utils/M9zService.ets new file mode 100644 index 0000000..bc469da --- /dev/null +++ b/entry/src/main/ets/utils/M9zService.ets @@ -0,0 +1,594 @@ +import { socket } from '@kit.ConnectivityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { util } from '@kit.ArkTS'; + +// 协议常量 +const FRAME_HEADER = 0xEE; +const FRAME_FOOTER = 0xFF; +const CMD_READ = 0x01; +const CMD_WRITE = 0x02; + +// 指令码 +export enum M9zCmd { + Manual = 0x01, // 手动控制 + DeviceTime = 0x02, // 设备时间 + DeviceOutStatus = 0x04, // 输出状态 + LgnLat = 0x05, // 经纬度 + SunRiseSet = 0x06, // 日出日落偏差 + Mode = 0x0B, // 模式选择 + DeviceStatus = 0x10, // 设备状态 + DeviceConfig = 0x11, // 设备配置 + SunRiseTime = 0x12, // 日出日落时间 + StartTask = 0x13, // 开始任务 + MiddleTask = 0x14, // 中间任务 + StopTask = 0x15, // 结束任务 + SubLoopParameters = 0x17,// 回路分控器参数 + RelayStatus = 0x19, // 继电器状态 + Restart = 0x7E, // 重启 + Save = 0x7F // 保存配置 +} + +// 接口定义 +export class LoopInfo { + index: number = 0; + name: string = ''; + isOpen: boolean = false; + pwm: number = 0; +} + +export class ManualData { + disable: boolean = false; // false=自动, true=手动 + loops: LoopInfo[] = []; +} + +export class DeviceStatus { + mode: number = 0; // 0=自动, 1=手动 + deviceTime: Date = new Date(); + relays: boolean[] = []; + pwmOutputs: number[] = []; +} + +export class LgnLatData { + lgn: number = 0; + lat: number = 0; + loc: string = ''; +} + +export class SunRiseSetData { + rise: number = 0; + set: number = 0; +} + +export class SunRiseTimeData { + sunrise: Date | null = null; + sunset: Date | null = null; +} + +export class TaskInstruction { + type: number = 0; + param: number = 0; + value: number = 0; +} + +export class TaskProgram { + index: number = 0; + instructions: TaskInstruction[] = []; +} + +// Data structures for UI/Logic representation +@Observed +export class TaskLoop { + index: number = 0; + turnOn: boolean = false; + pwm: number = 0; +} + +@Observed +export class ControlTask { + execTime: string = '00:00'; + timeType: number = 0; // 0=Timer, 1=Delay + loops: TaskLoop[] = []; +} + +// 定义联合类型以避免使用 any +export type ServiceData = boolean | number | Date | ManualData | LgnLatData | SunRiseSetData | SunRiseTimeData | TaskProgram | DeviceStatus; + +export interface Callback { + onSuccess: (data: T) => void; + onError: (err: string) => void; +} + +// 内部使用的通用回调包装类 - Exported to satisfy strict ArkTS Map generic constraints +export class GeneralCallbackWrapper { + private successCallback: (data: ServiceData) => void; + private errorCallback: (err: string) => void; + + constructor(success: (data: ServiceData) => void, error: (err: string) => void) { + this.successCallback = success; + this.errorCallback = error; + } + + public invokeSuccess(data: ServiceData): void { + this.successCallback(data); + } + + public invokeError(err: string): void { + this.errorCallback(err); + } +} + +export class M9zService { + private static instance: M9zService; + private clientNumber: number = -1; + private isConnected: boolean = false; + private readonly SPP_UUID: string = '00001101-0000-1000-8000-00805F9B34FB'; + + // Explicitly typed Map initialization + private callbackMap: Map = new Map(); + + private constructor() {} + + public static getInstance(): M9zService { + if (!M9zService.instance) { + M9zService.instance = new M9zService(); + } + return M9zService.instance; + } + + // 连接设备 + public connect(deviceId: string, onConnect: () => void, onError: (err: string) => void): void { + if (this.isConnected) { + onConnect(); + return; + } + + try { + socket.sppConnect(deviceId, { + uuid: this.SPP_UUID, + secure: true, + type: socket.SppType.SPP_RFCOMM + }, (err: BusinessError, clientNumber: number) => { + if (err) { + console.error('SPP 连接失败:', JSON.stringify(err)); + onError('连接失败: ' + err.message); + return; + } + + this.clientNumber = clientNumber; + this.isConnected = true; + console.info('SPP 连接成功, clientNumber:', clientNumber); + + // 监听数据接收 + socket.on('sppRead', this.clientNumber, (data: ArrayBuffer) => { + this.handleReceivedData(data); + }); + + onConnect(); + }); + } catch (err) { + console.error('SPP 连接异常:', JSON.stringify(err)); + onError('连接异常: ' + JSON.stringify(err)); + } + } + + // 断开连接 + public disconnect(): void { + if (this.clientNumber >= 0) { + try { + socket.off('sppRead', this.clientNumber); + socket.sppCloseClientSocket(this.clientNumber); + this.clientNumber = -1; + this.isConnected = false; + console.info('SPP 断开成功'); + } catch (err) { + console.error('SPP 断开失败:', JSON.stringify(err)); + } + } + } + + public getIsConnected(): boolean { + return this.isConnected; + } + + // 注册回调 + private registerCallback(instruction: number, callback: Callback): void { + if (!this.callbackMap.has(instruction)) { + this.callbackMap.set(instruction, []); + } + + // 创建包装对象 - explicitly typed + const wrapper: GeneralCallbackWrapper = new GeneralCallbackWrapper( + (data: ServiceData): void => { + callback.onSuccess(data as T); + }, + (err: string): void => { + callback.onError(err); + } + ); + + this.callbackMap.get(instruction)?.push(wrapper); + } + + // 发送数据 + private sendFrame(frame: number[]): void { + if (this.clientNumber < 0) { + console.error('未连接 SPP'); + return; + } + try { + const u8Array = new Uint8Array(frame); + const data = u8Array.buffer; + socket.sppWrite(this.clientNumber, data); + // Use helper with Uint8Array to avoid structural typing issues with ArrayBuffer + console.info('发送数据:', this.uint8ArrayToHexString(u8Array)); + } catch (err) { + console.error('发送数据失败:', JSON.stringify(err)); + } + } + + // 构建 M9Z 命令 + private buildM9zCommand(instruction: number, cmdType: number, index: number, data: number[]): number[] { + const payload: number[] = [instruction, cmdType, index]; + + // CMD_WRITE 且 data不为空时,写入长度 + if (cmdType === CMD_WRITE && data.length > 0) { + payload.push(data.length); // 写入命令中包含数据长度 + payload.push(...data); + } else if (cmdType === CMD_READ) { + // 读取命令一般不需要data长度字段,data本身为空 + } + + // 计算校验和 + let checksum = 0x33; + for (const byte of payload) { + checksum += byte; + } + checksum = checksum & 0xFF; + + return [FRAME_HEADER, ...payload, checksum, FRAME_FOOTER]; + } + + // 处理接收到的数据 + private handleReceivedData(data: ArrayBuffer): void { + const bytes = new Uint8Array(data); + console.info('接收数据:', this.uint8ArrayToHexString(bytes)); + + // 简单验证帧格式 + if (bytes.length < 5 || bytes[0] !== FRAME_HEADER || bytes[bytes.length - 1] !== FRAME_FOOTER) { + console.warn('无效的响应帧'); + return; + } + + const instruction = bytes[1]; + const cmdType = bytes[2]; + + // 检查是否为响应帧 (D7=1) 且是对应的 instruction + if ((instruction & 0x80) === 0) { + console.warn('非响应帧'); + return; + } + + const realInstruction = instruction & 0x7F; + const callbacks = this.callbackMap.get(realInstruction); + + // 根据 cmdType 解析 + if (cmdType === CMD_READ) { // 读响应 + // 格式: header(0) inst(1) cmdType(2) index(3) len(4) data... check footer + if (bytes.length < 6) return; + const len = bytes[4]; + if (bytes.length < 6 + len) return; + + // 拷贝数据以避免引用问题 + const payloadData: number[] = []; + for(let i=0; i 0) { + try { + const parsedData = this.parseData(realInstruction, payloadData); + callbacks.forEach(cb => cb.invokeSuccess(parsedData)); + } catch (e) { + console.error("解析错误", e); + callbacks.forEach(cb => cb.invokeError("解析错误")); + } + this.callbackMap.delete(realInstruction); // 清除回调 + } + + } else if (cmdType === CMD_WRITE) { // 写响应 + // 格式: header(0) inst(1) cmdType(2) index(3) len(4=1) success(5) check footer + if (bytes.length < 7) return; + const success = bytes[5] === 0x01; + if (callbacks && callbacks.length > 0) { + if (success) { + callbacks.forEach(cb => cb.invokeSuccess(true)); + } else { + callbacks.forEach(cb => cb.invokeError("设备返回失败")); + } + this.callbackMap.delete(realInstruction); + } + } + } + + private parseData(instruction: number, data: number[]): ServiceData { + switch (instruction) { + case M9zCmd.DeviceTime: // 0x02 + return this.parseDeviceTime(data); + case M9zCmd.Manual: // 0x01 + return this.parseManualData(data); + case M9zCmd.DeviceStatus: // 0x10 + return this.parseDeviceStatus(data); + case M9zCmd.LgnLat: // 0x05 + return this.parseLgnLat(data); + case M9zCmd.SunRiseSet: // 0x06 + return this.parseSunRiseSet(data); + case M9zCmd.StartTask: + case M9zCmd.MiddleTask: + case M9zCmd.StopTask: + return this.parseTask(data); + case M9zCmd.SunRiseTime: + return this.parseSunRiseTime(data); + case M9zCmd.Mode: + return data.length > 0 ? data[0] : 0; // 模式 + default: + return 0; // 默认返回 + } + } + + // --- 解析具体数据 --- + + private parseDeviceTime(data: number[]): Date { + if (data.length < 4) return new Date(); + const timestamp = this.bytesToUint32LE(data); + return new Date(timestamp * 1000); + } + + private parseManualData(data: number[]): ManualData { + // Byte0: disable (manual=1) + const disable = data[0] === 0x01; + // Byte2-3: RelayBits + const relayBits = data[2] | (data[3] << 8); + // Byte5-14: PWM + const loops: LoopInfo[] = []; + for (let i = 0; i < 10; i++) { + const loop = new LoopInfo(); + loop.index = i; + loop.name = `#${i + 1}`; + loop.isOpen = ((relayBits >> i) & 0x01) === 1; + loop.pwm = data[5 + i] || 0; + loops.push(loop); + } + const result = new ManualData(); + result.disable = disable; + result.loops = loops; + return result; + } + + private parseDeviceStatus(data: number[]): DeviceStatus { + // Byte0: Mode + const mode = data[0]; + // Byte1-4: Time + const timestamp = this.bytesToUint32LE(data.slice(1, 5)); + // Byte5-6: RelayBits + const relayBits = data[5] | (data[6] << 8); + const relays: boolean[] = []; + for (let i = 0; i < 10; i++) { + relays.push(((relayBits >> i) & 0x01) === 1); + } + // Byte8-17: PWM + const pwmOutputs: number[] = []; + for (let i = 0; i < 10; i++) { + pwmOutputs.push(data[8 + i]); + } + const status = new DeviceStatus(); + status.mode = mode; + status.deviceTime = new Date(timestamp * 1000); + status.relays = relays; + status.pwmOutputs = pwmOutputs; + return status; + } + + private parseLgnLat(data: number[]): LgnLatData { + const lgn = this.bytesToUint32LE(data.slice(0, 4)) / 1000000; + const lat = this.bytesToUint32LE(data.slice(4, 8)) / 1000000; + const res = new LgnLatData(); + res.lgn = lgn; + res.lat = lat; + res.loc = ''; + return res; + } + + private parseSunRiseSet(data: number[]): SunRiseSetData { + let rise = data[0]; + if (rise > 128) rise = rise - 256; // 转有符号 + let set = data[1]; + if (set > 128) set = set - 256; + const res = new SunRiseSetData(); + res.rise = rise; + res.set = set; + return res; + } + + private parseSunRiseTime(data: number[]): SunRiseTimeData { + const res = new SunRiseTimeData(); + // 假设返回两个 uint32 时间戳 (日出,日落) + if (data.length >= 8) { + const riseTs = this.bytesToUint32LE(data.slice(0, 4)); + const setTs = this.bytesToUint32LE(data.slice(4, 8)); + res.sunrise = new Date(riseTs * 1000); + res.sunset = new Date(setTs * 1000); + } + return res; + } + + private parseTask(data: number[]): TaskProgram { + // Byte0: Size + const size = data[0]; + const instructions: TaskInstruction[] = []; + let offset = 1; + for (let i = 0; i < size; i++) { + if (offset + 4 > data.length) break; + const val = this.bytesToUint32LE(data.slice(offset, offset + 4)); + const type = (val >> 28) & 0x0F; + let param = 0; + let value = 0; + + if (type === 0x01 || type === 0x03 || type === 0x04) { + param = (val >> 24) & 0x0F; + value = val & 0x00FFFFFF; + } else if (type === 0x02) { // GOTO + param = (val >> 24) & 0x0F; + value = val & 0x00FFFFFF; + } + + const ins = new TaskInstruction(); + ins.type = type; + ins.param = param; + ins.value = value; + instructions.push(ins); + offset += 4; + } + const prog = new TaskProgram(); + prog.index = 0; + prog.instructions = instructions; + return prog; + } + + // --- 公共 API --- + + public getDeviceTime(cb: Callback): void { + this.registerCallback(M9zCmd.DeviceTime, cb); + this.sendFrame(this.buildM9zCommand(M9zCmd.DeviceTime, CMD_READ, 0x00, [])); + } + + public setDeviceTime(date: Date, cb: Callback): void { + const timestamp = Math.floor(date.getTime() / 1000); + const data = this.uint32ToBytesLE(timestamp); + this.registerCallback(M9zCmd.DeviceTime, cb); + this.sendFrame(this.buildM9zCommand(M9zCmd.DeviceTime, CMD_WRITE, 0x00, data)); + } + + public getManualControlInfo(cb: Callback): void { + this.registerCallback(M9zCmd.Manual, cb); + this.sendFrame(this.buildM9zCommand(M9zCmd.Manual, CMD_READ, 0x00, [])); + } + + public setManualControl(data: ManualData, cb: Callback): void { + // Explicit number array initialization to avoid "any[]" + const payload: number[] = []; + for(let i=0; i<15; i++) payload.push(0); + + payload[0] = data.disable ? 0x01 : 0x00; + + let relayBits = 0; + data.loops.forEach(loop => { + if (loop.isOpen) relayBits |= (1 << loop.index); + }); + payload[2] = relayBits & 0xFF; + payload[3] = (relayBits >> 8) & 0xFF; + + data.loops.forEach(loop => { + payload[5 + loop.index] = loop.pwm; + }); + + this.registerCallback(M9zCmd.Manual, cb); + this.sendFrame(this.buildM9zCommand(M9zCmd.Manual, CMD_WRITE, 0x00, payload)); + } + + public getLgnLat(cb: Callback): void { + this.registerCallback(M9zCmd.LgnLat, cb); + this.sendFrame(this.buildM9zCommand(M9zCmd.LgnLat, CMD_READ, 0x00, [])); + } + + public setLgnLat(data: LgnLatData, cb: Callback): void { + // 构造12字节数据 + const lgnInt = Math.round(data.lgn * 1000000); + const latInt = Math.round(data.lat * 1000000); + const locInt = 8 * 3600 * 1000000; // 默认东八区? 暂时简化处理 + + const payload: number[] = [ + ...this.uint32ToBytesLE(lgnInt), + ...this.uint32ToBytesLE(latInt), + ...this.uint32ToBytesLE(locInt) + ]; + this.registerCallback(M9zCmd.LgnLat, cb); + this.sendFrame(this.buildM9zCommand(M9zCmd.LgnLat, CMD_WRITE, 0x00, payload)); + } + + public getSunRiseSet(cb: Callback): void { + this.registerCallback(M9zCmd.SunRiseSet, cb); + this.sendFrame(this.buildM9zCommand(M9zCmd.SunRiseSet, CMD_READ, 0x00, [])); + } + + public setSunRiseSet(data: SunRiseSetData, cb: Callback): void { + const payload = [ + data.rise < 0 ? data.rise + 256 : data.rise, + data.set < 0 ? data.set + 256 : data.set + ]; + this.registerCallback(M9zCmd.SunRiseSet, cb); + this.sendFrame(this.buildM9zCommand(M9zCmd.SunRiseSet, CMD_WRITE, 0x00, payload)); + } + + public getSunRiseTime(cb: Callback): void { + this.registerCallback(M9zCmd.SunRiseTime, cb); + this.sendFrame(this.buildM9zCommand(M9zCmd.SunRiseTime, CMD_READ, 0x00, [])); + } + + public getDeviceMode(cb: Callback): void { + this.registerCallback(M9zCmd.Mode, cb); // Mode read returns single byte + this.sendFrame(this.buildM9zCommand(M9zCmd.Mode, CMD_READ, 0x00, [])); + } + + public setDeviceMode(mode: number, cb: Callback): void { + this.registerCallback(M9zCmd.Mode, cb); + this.sendFrame(this.buildM9zCommand(M9zCmd.Mode, CMD_WRITE, 0x00, [mode])); + } + + public restartDevice(cb: Callback): void { + this.registerCallback(M9zCmd.Restart, cb); + this.sendFrame(this.buildM9zCommand(M9zCmd.Restart, CMD_WRITE, 0x00, [])); + } + + // 获取任务 + public getTask(cmd: M9zCmd, index: number, cb: Callback): void { + this.registerCallback(cmd, cb); + this.sendFrame(this.buildM9zCommand(cmd, CMD_READ, index, [])); + } + + // 设置任务 + public setTask(cmd: M9zCmd, task: TaskProgram, cb: Callback): void { + // 构建 payload + // Byte0: size + // Instructions... (4 bytes each) + const payload: number[] = [task.instructions.length]; + task.instructions.forEach(ins => { + // ins.type (4bit) | ins.param (4bit) | ins.value (24bit) + const val = ((ins.type & 0xF) << 28) | + ((ins.param & 0xF) << 24) | + (ins.value & 0xFFFFFF); + payload.push(...this.uint32ToBytesLE(val)); + }); + + this.registerCallback(cmd, cb); + this.sendFrame(this.buildM9zCommand(cmd, CMD_WRITE, task.index, payload)); + } + + + // 工具方法 + private uint8ArrayToHexString(bytes: Uint8Array): string { + return Array.from(bytes).map(b => b.toString(16).toUpperCase().padStart(2, '0')).join(' '); + } + + private bytesToUint32LE(bytes: number[]): number { + return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24); + } + + private uint32ToBytesLE(value: number): number[] { + return [ + value & 0xFF, + (value >> 8) & 0xFF, + (value >> 16) & 0xFF, + (value >> 24) & 0xFF + ]; + } +}