diff --git a/.doc/CSM-TCP-Router.excalidraw b/.doc/CSM-TCP-Router.excalidraw new file mode 100644 index 0000000..b9b5455 --- /dev/null +++ b/.doc/CSM-TCP-Router.excalidraw @@ -0,0 +1,1541 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor", + "elements": [ + { + "id": "06097f4ce4a944f5b840", + "type": "rectangle", + "x": -377.1428571428571, + "y": 1010.8571951729912, + "width": 710, + "height": 260, + "angle": 0, + "strokeColor": "#666666", + "backgroundColor": "#f5f5f5", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1516676960, + "version": 69, + "versionNonce": 1599584990, + "isDeleted": false, + "boundElements": [ + { + "id": "40d09f28b6f5449b8416", + "type": "text" + } + ], + "updated": 1777287137300, + "link": null, + "locked": false, + "index": "a0" + }, + { + "id": "40d09f28b6f5449b8416", + "type": "text", + "x": -140.4628571428571, + "y": 1125.8571951729912, + "width": 236.64, + "height": 30, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1359914060, + "version": 69, + "versionNonce": 1083748126, + "isDeleted": false, + "boundElements": [], + "updated": 1777287137300, + "link": null, + "locked": false, + "text": "Server Application", + "fontSize": 24, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "06097f4ce4a944f5b840", + "originalText": "Server Application", + "lineHeight": 1.25, + "autoResize": false, + "index": "a1" + }, + { + "id": "f2931a99db14441d92c2", + "type": "rectangle", + "x": 201.42848423549094, + "y": -51.142839704241055, + "width": 570, + "height": 630, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 334935149, + "version": 35, + "versionNonce": 479119682, + "isDeleted": false, + "boundElements": [ + { + "id": "d39d2f1397d84cf29c94", + "type": "text" + } + ], + "updated": 1777287133715, + "link": null, + "locked": false, + "index": "a2" + }, + { + "id": "d39d2f1397d84cf29c94", + "type": "text", + "x": 319.388484235491, + "y": 243.85716029575894, + "width": 334.08, + "height": 40, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1166131094, + "version": 35, + "versionNonce": 1613254914, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133715, + "link": null, + "locked": false, + "text": "Server Application", + "fontSize": 32, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "f2931a99db14441d92c2", + "originalText": "Server Application", + "lineHeight": 1.25, + "autoResize": false, + "index": "a3" + }, + { + "id": "3a12c9cd83ac49d4af82", + "type": "rectangle", + "x": 271.42848423549094, + "y": -1.1428397042410552, + "width": 450, + "height": 340, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 405658968, + "version": 53, + "versionNonce": 1320870082, + "isDeleted": false, + "boundElements": [ + { + "id": "96fc065142d741bfab28", + "type": "text" + }, + { + "id": "4343ca43bb1b46aaab89", + "type": "arrow" + } + ], + "updated": 1777287133715, + "link": null, + "locked": false, + "index": "a4" + }, + { + "id": "96fc065142d741bfab28", + "type": "text", + "x": 363.3184842354909, + "y": 3.857160295758945, + "width": 266.21999999999997, + "height": 33.75, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1687535625, + "version": 54, + "versionNonce": 870676610, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133715, + "link": null, + "locked": false, + "text": "CSM Module System", + "fontSize": 27, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "top", + "containerId": "3a12c9cd83ac49d4af82", + "originalText": "CSM Module System", + "lineHeight": 1.25, + "autoResize": false, + "index": "a5" + }, + { + "id": "58eb2e1dfd49455083d7", + "type": "rectangle", + "x": 288.2284720284597, + "y": 68.85716029575894, + "width": 203.20001220703125, + "height": 110, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 828310685, + "version": 70, + "versionNonce": 287227906, + "isDeleted": false, + "boundElements": [ + { + "id": "b4cb0e46d8754a6ba28b", + "type": "text" + } + ], + "updated": 1777287133715, + "link": null, + "locked": false, + "index": "a6" + }, + { + "id": "b4cb0e46d8754a6ba28b", + "type": "text", + "x": 304.7285406930105, + "y": 111.35716029575894, + "width": 170.1998748779297, + "height": 25, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 540651128, + "version": 72, + "versionNonce": 353071042, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133715, + "link": null, + "locked": false, + "text": "AI (CSM Module1)", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "58eb2e1dfd49455083d7", + "originalText": "AI (CSM Module1)", + "lineHeight": 1.25, + "autoResize": false, + "index": "a7" + }, + { + "id": "8a5c90e4c9214aff98d8", + "type": "rectangle", + "x": 288.2284720284597, + "y": 188.85716029575894, + "width": 203.20001220703125, + "height": 110, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 2061525606, + "version": 71, + "versionNonce": 525084546, + "isDeleted": false, + "boundElements": [ + { + "id": "15dba5756d714bc5be93", + "type": "text" + }, + { + "id": "c967d534ee594bd8b6f7", + "type": "arrow" + } + ], + "updated": 1777287133715, + "link": null, + "locked": false, + "index": "a8" + }, + { + "id": "15dba5756d714bc5be93", + "type": "text", + "x": 337.27851322719016, + "y": 218.85716029575894, + "width": 105.09992980957031, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1094784021, + "version": 71, + "versionNonce": 1478485826, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133715, + "link": null, + "locked": false, + "text": "DIO1 (CSM\nModule2)", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "8a5c90e4c9214aff98d8", + "originalText": "DIO1 (CSM Module2)", + "lineHeight": 1.25, + "autoResize": false, + "index": "a9" + }, + { + "id": "378abd886fa942b5ae6f", + "type": "rectangle", + "x": 501.42848423549094, + "y": 68.85716029575894, + "width": 200, + "height": 110, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1968915559, + "version": 52, + "versionNonce": 320634562, + "isDeleted": false, + "boundElements": [ + { + "id": "867f1a5210ac42c7a82d", + "type": "text" + } + ], + "updated": 1777287133715, + "link": null, + "locked": false, + "index": "aA" + }, + { + "id": "867f1a5210ac42c7a82d", + "type": "text", + "x": 548.8785193307058, + "y": 98.85716029575894, + "width": 105.09992980957031, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1862328426, + "version": 53, + "versionNonce": 1986221698, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133715, + "link": null, + "locked": false, + "text": "DIO1 (CSM\nModule3)", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "378abd886fa942b5ae6f", + "originalText": "DIO1 (CSM Module3)", + "lineHeight": 1.25, + "autoResize": false, + "index": "aB" + }, + { + "id": "adc34df6058e4435acf1", + "type": "rectangle", + "x": 501.42848423549094, + "y": 188.85716029575894, + "width": 200, + "height": 110, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 76596677, + "version": 52, + "versionNonce": 1115240002, + "isDeleted": false, + "boundElements": [ + { + "id": "109e949f39d34b6cb355", + "type": "text" + } + ], + "updated": 1777287133715, + "link": null, + "locked": false, + "index": "aC" + }, + { + "id": "109e949f39d34b6cb355", + "type": "text", + "x": 531.4185431344167, + "y": 218.85716029575894, + "width": 140.01988220214844, + "height": 50, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1205658623, + "version": 53, + "versionNonce": 406658562, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133715, + "link": null, + "locked": false, + "text": "Measure (CSM\nModule4)", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "adc34df6058e4435acf1", + "originalText": "Measure (CSM Module4)", + "lineHeight": 1.25, + "autoResize": false, + "index": "aD" + }, + { + "id": "72a83ed504ed4d0da987", + "type": "rectangle", + "x": 262.62843540736594, + "y": 438.85716029575894, + "width": 458.800048828125, + "height": 100, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 513845840, + "version": 87, + "versionNonce": 615386562, + "isDeleted": false, + "boundElements": [ + { + "id": "3b93d90e9ba740ec8d2c", + "type": "text" + }, + { + "id": "4343ca43bb1b46aaab89", + "type": "arrow" + } + ], + "updated": 1777287133715, + "link": null, + "locked": false, + "index": "aE" + }, + { + "id": "3b93d90e9ba740ec8d2c", + "type": "text", + "x": 272.20451572963157, + "y": 448.85716029575894, + "width": 439.64788818359375, + "height": 80, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 133198406, + "version": 89, + "versionNonce": 485164418, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133715, + "link": null, + "locked": false, + "text": "CSM TCP Router (based on\nJKI TCP Server)", + "fontSize": 32, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "72a83ed504ed4d0da987", + "originalText": "CSM TCP Router (based on JKI TCP Server)", + "lineHeight": 1.25, + "autoResize": false, + "index": "aF" + }, + { + "id": "66d9bb7d39ed49bab977", + "type": "rectangle", + "x": -556.1715218680247, + "y": -41.142839704241055, + "width": 647.6000061035156, + "height": 620, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 1857846310, + "version": 78, + "versionNonce": 904203522, + "isDeleted": false, + "boundElements": [ + { + "id": "d61e4a50c7424aafb49c", + "type": "text" + }, + { + "id": "c967d534ee594bd8b6f7", + "type": "arrow" + } + ], + "updated": 1777287133715, + "link": null, + "locked": false, + "index": "aG" + }, + { + "id": "d61e4a50c7424aafb49c", + "type": "text", + "x": -365.21946498325906, + "y": 248.85716029575894, + "width": 265.6958923339844, + "height": 40, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 531398026, + "version": 78, + "versionNonce": 1166023874, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133715, + "link": null, + "locked": false, + "text": "Client Application", + "fontSize": 32, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "66d9bb7d39ed49bab977", + "originalText": "Client Application", + "lineHeight": 1.25, + "autoResize": false, + "index": "aH" + }, + { + "id": "d4b5bc67b5e84b86aec2", + "type": "rectangle", + "x": -510.97150966099343, + "y": 161.12381837229248, + "width": 564.7999877929688, + "height": 190.39999050564236, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 451019402, + "version": 149, + "versionNonce": 1259164738, + "isDeleted": false, + "boundElements": [ + { + "id": "e14f5e9cd4674be1bda4", + "type": "text" + } + ], + "updated": 1777287133715, + "link": null, + "locked": false, + "index": "aI" + }, + { + "id": "e14f5e9cd4674be1bda4", + "type": "text", + "x": -505.97150966099343, + "y": 181.32381362511364, + "width": 479.3798828125, + "height": 150, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1029267775, + "version": 166, + "versionNonce": 1956477954, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133715, + "link": null, + "locked": false, + "text": "Server Build-in Command\n\nCSM TCP Router 定义的指令,用于管理和显示帮助\n- List: 列出所有的 CSM 模块\n- List API: 列出CSM 模块的参数\n- Help: 显示CSM模块的帮助(VI Description)", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "middle", + "containerId": "d4b5bc67b5e84b86aec2", + "originalText": "Server Build-in Command\n\nCSM TCP Router 定义的指令,用于管理和显示帮助\n- List: 列出所有的 CSM 模块\n- List API: 列出CSM 模块的参数\n- Help: 显示CSM模块的帮助(VI Description)", + "lineHeight": 1.25, + "autoResize": false, + "index": "aJ" + }, + { + "id": "c44e3d5dc3a5490cb039", + "type": "rectangle", + "x": -511.7714974539622, + "y": 369.39047644882606, + "width": 564.7999877929688, + "height": 145.06665943287038, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 286961250, + "version": 185, + "versionNonce": 290630594, + "isDeleted": false, + "boundElements": [ + { + "id": "ec5f57e035cf45908018", + "type": "text" + } + ], + "updated": 1777287133715, + "link": null, + "locked": false, + "index": "aK" + }, + { + "id": "ec5f57e035cf45908018", + "type": "text", + "x": -506.7714974539622, + "y": 379.4238061652612, + "width": 536.919921875, + "height": 125, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1260928053, + "version": 207, + "versionNonce": 122413954, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133715, + "link": null, + "locked": false, + "text": "Server  CSM Command\n由 CSM 模块定义的指令\nCSM 的所有消息,都被转发到 CSM Modules 中执行,因此\n支持的消息种类,由Server 中的CSM Module System 中的\nCSM模块决定", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "middle", + "containerId": "c44e3d5dc3a5490cb039", + "originalText": "Server  CSM Command\n由 CSM 模块定义的指令\nCSM 的所有消息,都被转发到 CSM Modules 中执行,因此支持的消息种类,由Server 中的CSM Module System 中的CSM模块决定", + "lineHeight": 1.25, + "autoResize": false, + "index": "aL" + }, + { + "id": "68d09aa25b504907b22d", + "type": "rectangle", + "x": -510.97150966099343, + "y": -11.142839704241055, + "width": 564.7999877929688, + "height": 167.7333249692564, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1280667824, + "version": 149, + "versionNonce": 549987138, + "isDeleted": false, + "boundElements": [ + { + "id": "0916692baae84d6a8cb2", + "type": "text" + } + ], + "updated": 1777287133715, + "link": null, + "locked": false, + "index": "aM" + }, + { + "id": "0916692baae84d6a8cb2", + "type": "text", + "x": -505.97150966099343, + "y": 10.223822780387138, + "width": 493.7198486328125, + "height": 125, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1861740026, + "version": 162, + "versionNonce": 136735490, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133715, + "link": null, + "locked": false, + "text": "Client-Def Command\nClient 定义的本地指令,和server无关\n\n- Switch: 切换发送的CSM模块,节省键入CSM模块名称\n- Script: 导入执行 scipt 文本", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "middle", + "containerId": "68d09aa25b504907b22d", + "originalText": "Client-Def Command\nClient 定义的本地指令,和server无关\n\n- Switch: 切换发送的CSM模块,节省键入CSM模块名称\n- Script: 导入执行 scipt 文本", + "lineHeight": 1.25, + "autoResize": false, + "index": "aN" + }, + { + "id": "c3d7e032eee9479d92fe", + "type": "rectangle", + "x": -347.1428571428571, + "y": 1040.8571951729912, + "width": 647.199951171875, + "height": 60, + "angle": 0, + "strokeColor": "#82b366", + "backgroundColor": "#d5e8d4", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 3 + }, + "seed": 616346550, + "version": 89, + "versionNonce": 1848712030, + "isDeleted": false, + "boundElements": [ + { + "id": "ba1ba4b5800841ef99ba", + "type": "text" + } + ], + "updated": 1777287137300, + "link": null, + "locked": false, + "index": "aO" + }, + { + "id": "ba1ba4b5800841ef99ba", + "type": "text", + "x": -154.28281075613836, + "y": 1055.8571951729912, + "width": 261.4798583984375, + "height": 30, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1360334683, + "version": 90, + "versionNonce": 1584529310, + "isDeleted": false, + "boundElements": [], + "updated": 1777287137300, + "link": null, + "locked": false, + "text": "TCP Layer( Reusable)", + "fontSize": 24, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "c3d7e032eee9479d92fe", + "originalText": "TCP Layer( Reusable)", + "lineHeight": 1.25, + "autoResize": false, + "index": "aP" + }, + { + "id": "f1fcce1f99dc4c1f8847", + "type": "rectangle", + "x": -347.1428571428571, + "y": 1110.8571951729912, + "width": 647.199951171875, + "height": 140, + "angle": 0, + "strokeColor": "#b85450", + "backgroundColor": "#f8cecc", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 527125112, + "version": 85, + "versionNonce": 361520094, + "isDeleted": false, + "boundElements": [ + { + "id": "5bab6bdeb62b40228319", + "type": "text" + } + ], + "updated": 1777287137300, + "link": null, + "locked": false, + "index": "aQ" + }, + { + "id": "5bab6bdeb62b40228319", + "type": "text", + "x": -309.78268868582586, + "y": 1168.3571951729912, + "width": 572.4796142578125, + "height": 25, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 894462135, + "version": 87, + "versionNonce": 1771122718, + "isDeleted": false, + "boundElements": [], + "updated": 1777287137300, + "link": null, + "locked": false, + "text": "Code Based CSM Framework(Based on the Requirements)", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "f1fcce1f99dc4c1f8847", + "originalText": "Code Based CSM Framework(Based on the Requirements)", + "lineHeight": 1.25, + "autoResize": false, + "index": "aR" + }, + { + "id": "5c296355ff174e0195e2", + "type": "rectangle", + "x": -272.85714285714266, + "y": 636.857125418527, + "width": 100, + "height": 100, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#D2D3D3", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 425614159, + "version": 71, + "versionNonce": 1181842526, + "isDeleted": false, + "boundElements": [ + { + "id": "b543c6d67fdf4c61a3f0", + "type": "text" + } + ], + "updated": 1777287137300, + "link": null, + "locked": false, + "index": "aS" + }, + { + "id": "b543c6d67fdf4c61a3f0", + "type": "text", + "x": -268.85714285714266, + "y": 661.857125418527, + "width": 92, + "height": 50, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 91453709, + "version": 73, + "versionNonce": 1912488094, + "isDeleted": false, + "boundElements": [], + "updated": 1777287137300, + "link": null, + "locked": false, + "text": "TCP\nClient", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "5c296355ff174e0195e2", + "originalText": "TCP Client", + "lineHeight": 1.25, + "autoResize": false, + "index": "aT" + }, + { + "id": "b56d185ccff9414282e7", + "type": "rectangle", + "x": -152.85714285714266, + "y": 636.857125418527, + "width": 100, + "height": 100, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#D2D3D3", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1346551058, + "version": 71, + "versionNonce": 1340636382, + "isDeleted": false, + "boundElements": [ + { + "id": "e1c67e3c14464e33954a", + "type": "text" + } + ], + "updated": 1777287137300, + "link": null, + "locked": false, + "index": "aU" + }, + { + "id": "e1c67e3c14464e33954a", + "type": "text", + "x": -148.85714285714266, + "y": 661.857125418527, + "width": 92, + "height": 50, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 757773874, + "version": 73, + "versionNonce": 633418014, + "isDeleted": false, + "boundElements": [], + "updated": 1777287137300, + "link": null, + "locked": false, + "text": "TCP\nClient", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "b56d185ccff9414282e7", + "originalText": "TCP Client", + "lineHeight": 1.25, + "autoResize": false, + "index": "aV" + }, + { + "id": "c8513775c6fe4da58f55", + "type": "rectangle", + "x": -32.85714285714266, + "y": 636.857125418527, + "width": 100, + "height": 100, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#D2D3D3", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 757589191, + "version": 71, + "versionNonce": 1593400670, + "isDeleted": false, + "boundElements": [ + { + "id": "709beda0e58b4b5ab6cd", + "type": "text" + } + ], + "updated": 1777287137300, + "link": null, + "locked": false, + "index": "aW" + }, + { + "id": "709beda0e58b4b5ab6cd", + "type": "text", + "x": -28.857142857142662, + "y": 661.857125418527, + "width": 92, + "height": 50, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1211271590, + "version": 73, + "versionNonce": 1356988830, + "isDeleted": false, + "boundElements": [], + "updated": 1777287137300, + "link": null, + "locked": false, + "text": "TCP\nClient", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "c8513775c6fe4da58f55", + "originalText": "TCP Client", + "lineHeight": 1.25, + "autoResize": false, + "index": "aX" + }, + { + "id": "09a22cc2431a4ca7b1e0", + "type": "rectangle", + "x": 33.42878069196445, + "y": 839.1429792131696, + "width": 651.1429268973216, + "height": 120, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1821414404, + "version": 187, + "versionNonce": 2125209054, + "isDeleted": false, + "boundElements": [ + { + "id": "627048f5d60843d0a878", + "type": "text" + } + ], + "updated": 1777287137300, + "link": null, + "locked": false, + "index": "aY" + }, + { + "id": "627048f5d60843d0a878", + "type": "text", + "x": 88.73049926757835, + "y": 849.1429792131696, + "width": 540.5394897460938, + "height": 100, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 215362962, + "version": 147, + "versionNonce": 1567142430, + "isDeleted": false, + "boundElements": [], + "updated": 1777287137300, + "link": null, + "locked": false, + "text": "TCP Command\n- All CSM command from your code is supported\n- System command provided by TCP Layer is supported\nlist/help/list api ...", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "09a22cc2431a4ca7b1e0", + "originalText": "TCP Command\n- All CSM command from your code is supported\n- System command provided by TCP Layer is supported\nlist/help/list api ...", + "lineHeight": 1.25, + "autoResize": false, + "index": "aZ" + }, + { + "id": "4343ca43bb1b46aaab89", + "type": "arrow", + "x": 494.02845982142844, + "y": 427.2571236746652, + "width": 0.00006103515625, + "height": 85.5999755859375, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1789827435, + "version": 197, + "versionNonce": 63192862, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133813, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0.00006103515625, + -85.5999755859375 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "72a83ed504ed4d0da987", + "focus": 0.008718202065400868, + "gap": 11.60003662109375 + }, + "endBinding": { + "elementId": "3a12c9cd83ac49d4af82", + "focus": 0.010665950554903025, + "gap": 2.79998779296875 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "index": "aa" + }, + { + "id": "c967d534ee594bd8b6f7", + "type": "arrow", + "x": 91.42848423549094, + "y": 477.85716029575894, + "width": 176.19998168945312, + "height": 218.79998779296875, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1930751864, + "version": 148, + "versionNonce": 1475973022, + "isDeleted": false, + "boundElements": [], + "updated": 1777287133814, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 176.19998168945312, + -218.79998779296875 + ] + ], + "lastCommittedPoint": null, + "startBinding": { + "elementId": "66d9bb7d39ed49bab977", + "focus": 0.8581630760314127, + "gap": 1 + }, + "endBinding": { + "elementId": "8a5c90e4c9214aff98d8", + "focus": 0.7537063280415125, + "gap": 20.600006103515625 + }, + "startArrowhead": null, + "endArrowhead": "arrow", + "index": "ab" + }, + { + "id": "c9b3b417bdbf45d89ce0", + "type": "arrow", + "x": -222.85714285714266, + "y": 750.8571951729912, + "width": 120, + "height": 280, + "angle": 0, + "strokeColor": "#97D077", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 4, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1801585414, + "version": 120, + "versionNonce": 403226206, + "isDeleted": false, + "boundElements": [], + "updated": 1777287137300, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 120, + 280 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "index": "ac" + }, + { + "id": "5f55e1df753d4fb2a6b9", + "type": "arrow", + "x": -102.85714285714266, + "y": 750.8571951729912, + "width": 0, + "height": 280, + "angle": 0, + "strokeColor": "#97D077", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 4, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1574452212, + "version": 51, + "versionNonce": 1398620830, + "isDeleted": false, + "boundElements": [], + "updated": 1777287137300, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0, + 280 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "index": "ad" + }, + { + "id": "7a7d7908178144bb97dd", + "type": "arrow", + "x": 17.142857142857338, + "y": 750.8571951729912, + "width": 120, + "height": 280, + "angle": 0, + "strokeColor": "#97D077", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 4, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1366313377, + "version": 51, + "versionNonce": 1914126046, + "isDeleted": false, + "boundElements": [], + "updated": 1777287137300, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -120, + 280 + ] + ], + "lastCommittedPoint": null, + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow", + "index": "ae" + } + ], + "appState": { + "gridSize": 20, + "gridStep": 5, + "gridModeEnabled": false, + "viewBackgroundColor": "#ffffff" + }, + "files": {} +} \ No newline at end of file diff --git a/.doc/CSM-TCP-Router.png b/.doc/CSM-TCP-Router.png new file mode 100644 index 0000000..37d8fab Binary files /dev/null and b/.doc/CSM-TCP-Router.png differ diff --git a/.doc/Protocol.v0.(zh-cn).md b/.doc/Protocol.v0.(zh-cn).md index e4f5321..67ba818 100644 --- a/.doc/Protocol.v0.(zh-cn).md +++ b/.doc/Protocol.v0.(zh-cn).md @@ -200,6 +200,7 @@ sequenceDiagram > [!NOTE] > `status` 和 `interrupt` 两种订阅广播类型均受支持: +> > - `status`(`0x06`):普通广播,订阅模块的常规状态变化 > - `interrupt`(`0x07`):中断广播,订阅模块触发的中断事件 > diff --git a/.doc/csm-tcp-router-client-console.excalidraw b/.doc/csm-tcp-router-client-console.excalidraw new file mode 100644 index 0000000..91524ac --- /dev/null +++ b/.doc/csm-tcp-router-client-console.excalidraw @@ -0,0 +1,14 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + {"id":"u","type":"text","x":80,"y":60,"width":52,"height":24,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":21,"version":1,"versionNonce":21,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"User","fontSize":20,"fontFamily":1,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"User","lineHeight":1.2}, + {"id":"c","type":"text","x":290,"y":60,"width":150,"height":24,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":22,"version":1,"versionNonce":22,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"Client Console","fontSize":20,"fontFamily":1,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Client Console","lineHeight":1.2}, + {"id":"r","type":"text","x":570,"y":60,"width":150,"height":24,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":23,"version":1,"versionNonce":23,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"TCP Router","fontSize":20,"fontFamily":1,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"TCP Router","lineHeight":1.2}, + {"id":"s","type":"text","x":830,"y":60,"width":104,"height":24,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":24,"version":1,"versionNonce":24,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"Server Log","fontSize":20,"fontFamily":1,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Server Log","lineHeight":1.2}, + {"id":"note","type":"text","x":140,"y":180,"width":760,"height":72,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":25,"version":1,"versionNonce":25,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"Connect -> welcome(info) -> send cmd -> resp/async-resp -> Bye -> goodbye(info)","fontSize":24,"fontFamily":1,"textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Connect -> welcome(info) -> send cmd -> resp/async-resp -> Bye -> goodbye(info)","lineHeight":1.2} + ], + "appState": {"gridSize": null, "viewBackgroundColor": "#ffffff"}, + "files": {} +} diff --git a/.doc/csm-tcp-router-client-console.png b/.doc/csm-tcp-router-client-console.png new file mode 100644 index 0000000..e1998f3 Binary files /dev/null and b/.doc/csm-tcp-router-client-console.png differ diff --git a/.doc/csm-tcp-router-command-sets.excalidraw b/.doc/csm-tcp-router-command-sets.excalidraw new file mode 100644 index 0000000..4d00445 --- /dev/null +++ b/.doc/csm-tcp-router-command-sets.excalidraw @@ -0,0 +1,17 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + {"id":"r0","type":"rectangle","x":420,"y":40,"width":240,"height":70,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#fff7ed","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":11,"version":1,"versionNonce":11,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false}, + {"id":"t0","type":"text","x":470,"y":64,"width":140,"height":24,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":12,"version":1,"versionNonce":12,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"Command Sets","fontSize":20,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":null,"originalText":"Command Sets","lineHeight":1.2}, + {"id":"r1","type":"rectangle","x":80,"y":200,"width":280,"height":82,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#eff6ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":13,"version":1,"versionNonce":13,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false}, + {"id":"t1","type":"text","x":110,"y":230,"width":220,"height":24,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":14,"version":1,"versionNonce":14,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"1) CSM Message APIs","fontSize":20,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":null,"originalText":"1) CSM Message APIs","lineHeight":1.2}, + {"id":"r2","type":"rectangle","x":430,"y":200,"width":280,"height":82,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#f0fdfa","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":15,"version":1,"versionNonce":15,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false}, + {"id":"t2","type":"text","x":444,"y":230,"width":252,"height":24,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":16,"version":1,"versionNonce":16,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"2) Router Management APIs","fontSize":20,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":null,"originalText":"2) Router Management APIs","lineHeight":1.2}, + {"id":"r3","type":"rectangle","x":780,"y":200,"width":280,"height":82,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#faf5ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":17,"version":1,"versionNonce":17,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false}, + {"id":"t3","type":"text","x":820,"y":230,"width":200,"height":24,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":18,"version":1,"versionNonce":18,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"3) Client Built-ins","fontSize":20,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":null,"originalText":"3) Client Built-ins","lineHeight":1.2} + ], + "appState": {"gridSize": null, "viewBackgroundColor": "#ffffff"}, + "files": {} +} diff --git a/.doc/csm-tcp-router-command-sets.png b/.doc/csm-tcp-router-command-sets.png new file mode 100644 index 0000000..4ecf947 Binary files /dev/null and b/.doc/csm-tcp-router-command-sets.png differ diff --git a/.doc/csm-tcp-router-framework.excalidraw b/.doc/csm-tcp-router-framework.excalidraw new file mode 100644 index 0000000..30e1882 --- /dev/null +++ b/.doc/csm-tcp-router-framework.excalidraw @@ -0,0 +1,17 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + {"id":"c1","type":"rectangle","x":40,"y":160,"width":190,"height":72,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#e8f4ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":1,"version":1,"versionNonce":1,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false}, + {"id":"t1","type":"text","x":72,"y":186,"width":126,"height":24,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":2,"version":1,"versionNonce":2,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"TCP Clients","fontSize":20,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":null,"originalText":"TCP Clients","lineHeight":1.2}, + {"id":"c2","type":"rectangle","x":290,"y":160,"width":230,"height":72,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#ecfeff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":3,"version":1,"versionNonce":3,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false}, + {"id":"t2","type":"text","x":322,"y":186,"width":166,"height":24,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":4,"version":1,"versionNonce":4,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"CSM-TCP-Router","fontSize":20,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":null,"originalText":"CSM-TCP-Router","lineHeight":1.2}, + {"id":"c3","type":"rectangle","x":580,"y":160,"width":220,"height":72,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#f5f3ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":5,"version":1,"versionNonce":5,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false}, + {"id":"t3","type":"text","x":620,"y":186,"width":140,"height":24,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":6,"version":1,"versionNonce":6,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"CSM Bus","fontSize":20,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":null,"originalText":"CSM Bus","lineHeight":1.2}, + {"id":"c4","type":"rectangle","x":860,"y":160,"width":210,"height":72,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"#f0fdf4","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":7,"version":1,"versionNonce":7,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false}, + {"id":"t4","type":"text","x":884,"y":186,"width":162,"height":24,"angle":0,"strokeColor":"#1e1e1e","backgroundColor":"transparent","fillStyle":"hachure","strokeWidth":1,"strokeStyle":"solid","roughness":0,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":8,"version":1,"versionNonce":8,"isDeleted":false,"boundElements":[],"updated":1,"link":null,"locked":false,"text":"CSM Modules","fontSize":20,"fontFamily":1,"textAlign":"center","verticalAlign":"middle","containerId":null,"originalText":"CSM Modules","lineHeight":1.2} + ], + "appState": {"gridSize": null, "viewBackgroundColor": "#ffffff"}, + "files": {} +} diff --git a/.doc/image.png b/.doc/csm-tcp-router-framework.png similarity index 100% rename from .doc/image.png rename to .doc/csm-tcp-router-framework.png diff --git a/.doc/Protocol.v1.(zh-cn).md b/.doc/obsolete/Protocol.v1.(zh-cn).md similarity index 100% rename from .doc/Protocol.v1.(zh-cn).md rename to .doc/obsolete/Protocol.v1.(zh-cn).md diff --git a/.github/workflows/CSharp_SDK.yml b/.github/workflows/CSharp_SDK.yml new file mode 100644 index 0000000..a402bde --- /dev/null +++ b/.github/workflows/CSharp_SDK.yml @@ -0,0 +1,124 @@ +name: CSharp SDK + +on: + push: + paths: + - 'SDK/csharp/**' + - '.github/workflows/CSharp_SDK.yml' + tags: + - 'csharp-sdk-v*' + pull_request: + paths: + - 'SDK/csharp/**' + - '.github/workflows/CSharp_SDK.yml' + workflow_dispatch: + +defaults: + run: + working-directory: SDK/csharp + +jobs: + # ------------------------------------------------------------------------- + # Build + test on multiple OSes + # ------------------------------------------------------------------------- + test: + name: Build & Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET 8 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore CsmTcpRouter.sln + + - name: Build + run: dotnet build CsmTcpRouter.sln -c Release --no-restore + + - name: Test + run: dotnet test tests/CsmTcpRouter.Tests/CsmTcpRouter.Tests.csproj -c Release --no-build --logger "trx;LogFileName=test-results.trx" --collect:"XPlat Code Coverage" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }} + path: SDK/csharp/tests/CsmTcpRouter.Tests/TestResults/ + if-no-files-found: ignore + + # ------------------------------------------------------------------------- + # Pack the NuGet package + # ------------------------------------------------------------------------- + pack: + name: Pack NuGet + runs-on: ubuntu-latest + needs: test + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set up .NET 8 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Pack + run: dotnet pack src/CsmTcpRouter/CsmTcpRouter.csproj -c Release -o nupkg + + - name: Upload NuGet artifact + uses: actions/upload-artifact@v4 + with: + name: csharp-sdk-nupkg + path: SDK/csharp/nupkg/ + + # ------------------------------------------------------------------------- + # Publish to NuGet.org (on tag push only) + # ------------------------------------------------------------------------- + publish: + name: Publish to NuGet.org + runs-on: ubuntu-latest + needs: pack + if: startsWith(github.ref, 'refs/tags/csharp-sdk-v') + environment: + name: nuget + url: https://www.nuget.org/packages/CsmTcpRouter.Client/ + + permissions: + contents: read + + steps: + - name: Download NuGet artifact + uses: actions/download-artifact@v4 + with: + name: csharp-sdk-nupkg + path: nupkg + + - name: Set up .NET 8 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Push to NuGet.org + # NUGET_API_KEY must be configured as a secret in the `nuget` + # environment. The workflow is gated by the `csharp-sdk-v*` tag + # filter above to prevent accidental publishes. + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: | + dotnet nuget push "nupkg/*.nupkg" \ + --api-key "$NUGET_API_KEY" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate + working-directory: ${{ github.workspace }} diff --git a/.github/workflows/C_SDK.yml b/.github/workflows/C_SDK.yml new file mode 100644 index 0000000..81d4ee2 --- /dev/null +++ b/.github/workflows/C_SDK.yml @@ -0,0 +1,67 @@ +name: C SDK + +on: + push: + paths: + - 'SDK/c/**' + - '.github/workflows/C_SDK.yml' + tags: + - 'c-sdk-v*' + pull_request: + paths: + - 'SDK/c/**' + - '.github/workflows/C_SDK.yml' + workflow_dispatch: + +defaults: + run: + working-directory: SDK/c + +jobs: + # ------------------------------------------------------------------------- + # Build & test on Linux, macOS, and Windows. + # ------------------------------------------------------------------------- + build-test: + name: Build & test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Configure (CMake) + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: cmake --build build --config Release -j + + - name: Run tests (ctest) + run: ctest --test-dir build --output-on-failure -C Release + + # ------------------------------------------------------------------------- + # Sanitizer build on Linux to catch undefined behaviour, leaks, and races. + # ------------------------------------------------------------------------- + sanitizers: + name: Sanitizers (Linux) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Configure with ASan + UBSan + run: | + cmake -S . -B build-san \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -O1 -g" \ + -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" + + - name: Build (sanitizers) + run: cmake --build build-san -j + + - name: Run tests (sanitizers) + run: ctest --test-dir build-san --output-on-failure diff --git a/.github/workflows/Python_SDK.yml b/.github/workflows/Python_SDK.yml new file mode 100644 index 0000000..d12bbda --- /dev/null +++ b/.github/workflows/Python_SDK.yml @@ -0,0 +1,157 @@ +name: Python SDK + +on: + push: + paths: + - 'SDK/python/**' + tags: + - 'python-sdk-v*' + pull_request: + paths: + - 'SDK/python/**' + workflow_dispatch: + +defaults: + run: + working-directory: SDK/python + +jobs: + # ------------------------------------------------------------------------- + # Lint + # ------------------------------------------------------------------------- + lint: + name: Lint (ruff) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install ruff + run: pip install ruff + + - name: Run ruff + run: ruff check src/ tests/ examples/ + + # ------------------------------------------------------------------------- + # Test matrix + # ------------------------------------------------------------------------- + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + needs: lint + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package and test dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest pytest-cov pytest-asyncio + + - name: Run tests + run: pytest --cov=csm_tcp_router_client --cov-report=term-missing + + # ------------------------------------------------------------------------- + # Build (wheel + sdist) + # ------------------------------------------------------------------------- + build: + name: Build distribution + runs-on: ubuntu-latest + needs: test + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tools + run: pip install build + + - name: Build wheel and sdist + run: python -m build + + - name: Verify wheel is importable + run: | + pip install dist/*.whl + python -c "import csm_tcp_router_client; print('Version:', csm_tcp_router_client.__version__)" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: python-sdk-dist + path: SDK/python/dist/ + + # ------------------------------------------------------------------------- + # Publish to TestPyPI (on tag push only – gates production publish) + # ------------------------------------------------------------------------- + publish-testpypi: + name: Publish to TestPyPI + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/python-sdk-v') + environment: + name: testpypi + url: https://test.pypi.org/project/csm-tcp-router-client/ + + permissions: + id-token: write # required for OIDC trusted publishing + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: python-sdk-dist + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + # ------------------------------------------------------------------------- + # Publish to PyPI (on tag push only – after TestPyPI succeeds) + # ------------------------------------------------------------------------- + publish: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: publish-testpypi + if: startsWith(github.ref, 'refs/tags/python-sdk-v') + environment: + name: pypi + url: https://pypi.org/project/csm-tcp-router-client/ + + permissions: + id-token: write # required for trusted publishing (OIDC) + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: python-sdk-dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 10380b3..105600d 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,25 @@ Thumbs.db ._* .Spotlight-V100 .Trashes -ehthumbs.db \ No newline at end of file +ehthumbs.db + +# C# / .NET +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ +*.user +*.suo +*.userosscache +*.sln.docstates +.vs/ +*.nupkg +*.snupkg +nupkg/ +TestResults/ +[Tt]est[Rr]esults/ +coverage.cobertura.xml +*.received.* +project.lock.json +project.fragment.lock.json +artifacts/ \ No newline at end of file diff --git a/CSM-TCP-Router.lvproj b/CSM-TCP-Router.lvproj index 123e3ed..92feb53 100644 --- a/CSM-TCP-Router.lvproj +++ b/CSM-TCP-Router.lvproj @@ -89,6 +89,7 @@ + @@ -105,6 +106,9 @@ + + + diff --git a/README(zh-cn).md b/README(zh-cn).md index 484cbfc..0c2353f 100644 --- a/README(zh-cn).md +++ b/README(zh-cn).md @@ -2,92 +2,77 @@ [English](./README.md) | [中文](./README(zh-cn).md) -本仓库演示如何构建一个可复用的TCP通讯层(CSM-TCP-Router),将本地程序变成可远程控制的TCP服务器,展示了CSM框架隐形总线机制的优势。 +CSM-TCP-Router 是一个可复用的 CSM TCP 通讯层。 +它通过 CSM 隐形总线机制,将本地 CSM 程序转换为可远程控制的 TCP 服务器。 -## 功能介绍 +## 功能特性 -![framework](.doc/CSM-TCP-Router%201.svg) +![CSM-TCP-Router框架架构图,展示远程TCP客户端经由Router和CSM隐形总线到本地CSM模块](.doc/csm-tcp-router-framework.png) -- 所有本地可发送的CSM消息,均可通过TCP连接以CSM同步或异步格式转发给本地程序。 -- 基于JKI-TCP-Server库,支持多个TCP客户端同时连接。 -- [client] 提供一个标准的TCP客户端,可以连接到服务器,验证远程连接、消息发送等功能。 +图示对应的 Excalidraw 源文件位于 `.doc/*.excalidraw`。 + +- 所有本地可发送的 CSM 消息都可通过 TCP 以同步或异步格式转发。 +- 基于 JKI TCP Server,支持多个客户端并发连接。 +- 仓库内置标准客户端,可用于连接与指令联调验证。 ## 通讯协议 -CSM-TCP-Router 中 TCP 数据包格式定义如下: +TCP 数据包格式如下: ``` -| 数据长度(4B) | 版本(1B) | TYPE(1B) | FLAG1(1B) | FLAG2(1B) | 文本数据 | -╰─────────────────────────── 包头 ──────────────────────────╯╰──── 数据长度范围 ─────╯ +| 数据长度(4B) | 版本(1B) | TYPE(1B) | FLAG1(1B) | FLAG2(1B) | 文本数据 | +╰─────────────────────────── 包头 ───────────────────────────╯╰──── 数据长度范围 ────╯ ``` -数据包类型字段为枚举值,用于描述数据包内容,目前支持以下类型: +`TYPE` 字段当前支持: -- 信息数据包(`info`) - `0x00`:服务端在客户端连接时(欢迎消息)和连接关闭时(告别消息)发送 -- 错误数据包(`error`) - `0x01` -- 指令数据包(`cmd`) - `0x02` -- 指令响应数据包(`cmd-resp`) - `0x03` -- 同步响应数据包(`resp`) - `0x04` -- 异步响应数据包(`async-resp`) - `0x05` -- 状态广播数据包(`status`) - `0x06` -- 中断广播数据包(`interrupt`) - `0x07` +- `info` (`0x00`):客户端连接(欢迎)和断开(告别)时发送 +- `error` (`0x01`) +- `cmd` (`0x02`) +- `cmd-resp` (`0x03`) +- `resp` (`0x04`) +- `async-resp` (`0x05`) +- `status` (`0x06`) +- `interrupt` (`0x07`) -详细的通讯协议定义, 见 [协议设计](.doc/Protocol.v0.(zh-cn).md) +完整协议请见 [协议设计](.doc/Protocol.v0.(zh-cn).md)。 ## 指令集 -![image](.doc/CSM-TCP-Router.drawio.png) - -### 1. CSM 消息指令集 - -由现有基于CSM开发的代码定义。CSM框架通过隐形总线传递消息,无需侵入原有代码即可实现远程通讯。 - -例如,本程序中的AI CSM模块提供了: - -- `Channels`: 列出所有的通道 -- `Read`:读取指定通道的值 -- `read all`:读取所有通道的值 - -这些消息可以通过TCP连接发送给本地程序,实现远程控制。 +![指令集图示,包含CSM消息API、Router管理API和Client内建指令三类](.doc/csm-tcp-router-command-sets.png) -### 2. CSM-TCP-Router 指令集 +### 1)CSM 消息 API -由CSM-TCP-Router通讯层定义,通过指令暴露CSM模块的管理功能,实现远程控制。 +由现有 CSM 业务代码定义,可经 Router 直接转发,无需侵入式改造。 -- `List`: 列出所有CSM模块 -- `List API`: 列出指定模块的所有API -- `List State`: 列出指定模块的所有CSM状态 -- `Help`: 显示模块的帮助文件(存储在CSM VI的Documentation字段) -- `Refresh lvcsm`: 刷新缓存文件 +### 2)Router 管理 API -### [Client Only] 3. CSM-TCP-Router Client 指令集 +由 CSM-TCP-Router 定义,用于模块管理与运行态查询。 -代码中内置了一个标准的CSM-TCP-Router客户端,包含一些专属内置指令,这些指令在基于指令集API进行二次开发时无法使用。 +### 3)Client 内建指令(仅客户端) -- `Bye`: 断开连接 -- `Switch`:切换模块,便于输入时省略模块名,不带参数时切换回默认模式 -- TAB键: 自动定位到输入对话框 +由内置客户端提供,不属于二次开发时可扩展的指令集 API。 -![CSM-TCP-Router Client Console](.doc/Client.png) +![客户端交互流程图,展示连接、发送指令、响应、记录日志和断开连接](.doc/csm-tcp-router-client-console.png) ## 使用方法 -1. 在VIPM中安装本工具及依赖 -2. 在CSM的范例中打开范例工程CSM-TCP-Router.lvproj -3. 启动代码工程中的CSM-TCP-Router(Server).vi -4. 启动Client.vi,输入服务器的IP地址和端口号,点击连接 -5. 输入指令,点击发送,可以在控制台看到返回的消息 -6. 在Server程序的界面log中,可以看到执行过的历史消息 -7. 在Client.vi中输入`Bye`断开连接 -8. 关闭Server程序 +1. 在 VIPM 中安装本工具及依赖。 +2. 在 CSM 范例中打开 `CSM-TCP-Router.lvproj`。 +3. 启动 `CSM-TCP-Router(Server).vi`。 +4. 启动 `Client.vi`,输入服务端 IP 和端口并连接。 +5. 发送指令,在客户端控制台查看返回消息。 +6. 在服务端日志界面查看历史执行记录。 +7. 在 `Client.vi` 中输入 `Bye` 断开连接。 +8. 关闭服务端程序。 ### 下载 -通过VIPM搜索CSM TCP Router,即可下载安装。 +在 VIPM 中搜索 `CSM TCP Router` 并安装。 ### 依赖 -- Communicable State Machine(CSM) - NEVSTOP +- Communicable State Machine (CSM) - NEVSTOP - JKI TCP Server - JKI - Global Stop - NEVSTOP - OpenG diff --git a/README.md b/README.md index 80c19d0..2df5371 100644 --- a/README.md +++ b/README.md @@ -2,88 +2,73 @@ [English](./README.md) | [中文](./README(zh-cn).md) -This repository demonstrates how to build a reusable TCP communication layer—CSM-TCP-Router—that turns a local program into a TCP server for remote control, showcasing the power of the CSM framework's invisible bus. +CSM-TCP-Router is a reusable TCP communication layer for CSM applications. +It turns a local CSM program into a remotely controllable TCP server through the CSM invisible bus mechanism. ## Features -![framework](.doc/CSM-TCP-Router%201.svg) +![CSM-TCP-Router framework architecture diagram from remote TCP clients to local CSM modules via router and CSM bus](.doc/csm-tcp-router-framework.png) -- Any CSM message that can be sent locally can also be transmitted to the local program over TCP, using CSM synchronous and asynchronous message formats. -- Based on the JKI-TCP-Server library, it supports multiple TCP clients connecting simultaneously. -- [client] Provides a standard TCP client that can connect to the server to verify remote connections and message sending. +Excalidraw source files for the diagrams are in `.doc/*.excalidraw`. + +- Any CSM message available locally can be forwarded through TCP in synchronous or asynchronous format. +- Based on JKI TCP Server, it supports multiple concurrent client connections. +- The repository includes a standard client for connection and command verification. ## Protocol -The TCP packet format used in the CSM-TCP-Router is defined as follows: +TCP packet format: ``` -| Data Length (4B) | Version (1B) | TYPE (1B) | FLAG1 (1B) | FLAG2 (1B) | Text Data | -╰───────────────────────────────── Header ──────────────────────────────╯╰─── Data Length Range ──╯ +| Data Length (4B) | Version (1B) | TYPE (1B) | FLAG1 (1B) | FLAG2 (1B) | Text Data | +╰──────────────────────────── Header ────────────────────────────╯╰─ Data Length Range ─╯ ``` -This field specifies the packet type as an enumerated value. Supported types are: - -- Information Packet (`info`) - `0x00`: Sent by the server when a client connects (welcome message) and when the connection is closed (goodbye message) -- Error Packet (`error`) - `0x01` -- Command Packet (`cmd`) - `0x02` -- Command Response Packet (`cmd-resp`) - `0x03` -- Synchronous Response Packet (`resp`) - `0x04` -- Asynchronous Response Packet (`async-resp`) - `0x05` -- Status Broadcast Packet (`status`) - `0x06` -- Interrupt Broadcast Packet (`interrupt`) - `0x07` - -For detailed communication protocol definitions, see [Protocol Design](.doc/Protocol.v0.(en).md). - -## Supported Command Sets - -![image](.doc/CSM-TCP-Router.drawio.png) - -### 1. CSM Message Command Set +Supported packet `TYPE` values: -Defined by the existing CSM-based application code. Because the CSM framework uses an invisible bus for message passing, remote communication requires no intrusive changes to the existing code. +- `info` (`0x00`): sent on connect (welcome) and disconnect (goodbye) +- `error` (`0x01`) +- `cmd` (`0x02`) +- `cmd-resp` (`0x03`) +- `resp` (`0x04`) +- `async-resp` (`0x05`) +- `status` (`0x06`) +- `interrupt` (`0x07`) -For example, the AI CSM module in this program provides: +See [Protocol Design](.doc/Protocol.v0.(en).md) for full details. -- `Channels`: List all channels -- `Read`: Read the value of a specified channel -- `read all`: Read the values of all channels +## Command Sets -These messages can be sent to the local program via TCP connection for remote control. +![Command sets diagram with CSM Message APIs, Router Management APIs, and Client Built-ins](.doc/csm-tcp-router-command-sets.png) -### 2. CSM-TCP-Router Command Set +### 1) CSM Message APIs -Defined by the CSM-TCP-Router layer. These commands expose the management functions of CSM modules for remote control. +Defined by existing CSM-based application code and forwarded through the router without intrusive changes. -- `List`: List all CSM modules -- `List API`: List all APIs of a specified module -- `List State`: List all CSM states of a specified module -- `Help`: Display the help file of the module, stored in the Documentation field of the CSM VI -- `Refresh lvcsm`: Refresh the cache file +### 2) Router Management APIs -### [Client Only] 3. CSM-TCP-Router Client Command Set +Defined by CSM-TCP-Router for module management and runtime inspection. -The bundled standard CSM-TCP-Router Client includes additional built-in commands that are not available when building on the command set API. +### 3) Client Built-ins (Client only) -- `Bye`: Disconnect -- `Switch`: Switch the active module to omit the module name when entering commands; omit the parameter to switch back to the default mode -- TAB key: Automatically focus on the input dialog box +Built into the bundled client and not available through secondary API development. -![CSM-TCP-Router Client Console](.doc/Client.png) +![Client interaction flow from connect and command send to response, log record, and disconnect](.doc/csm-tcp-router-client-console.png) ## Usage -1. Install this tool and dependencies via VIPM -2. Open the example project CSM-TCP-Router.lvproj in the CSM examples -3. Start the CSM-TCP-Router(Server).vi in the code project -4. Start Client.vi, enter the server's IP address and port number, and click connect -5. Enter commands and click send to see the returned messages in the console -6. View the history of executed messages in the log interface of the Server program -7. Enter `Bye` in Client.vi to disconnect -8. Close the Server program +1. Install this package and dependencies in VIPM. +2. Open `CSM-TCP-Router.lvproj` from CSM examples. +3. Run `CSM-TCP-Router(Server).vi`. +4. Run `Client.vi`, enter server IP/port, and connect. +5. Send commands and check returned messages in the client console. +6. Check execution history in the server log panel. +7. Enter `Bye` in `Client.vi` to disconnect. +8. Stop the server. ### Download -Search for CSM TCP Router in VIPM to download and install. +Search `CSM TCP Router` in VIPM and install. ### Dependencies diff --git a/SDK/PythonClientAPI/README.md b/SDK/PythonClientAPI/README.md deleted file mode 100644 index 78a0e18..0000000 --- a/SDK/PythonClientAPI/README.md +++ /dev/null @@ -1,166 +0,0 @@ -# CSM-TCP-Router Python Client API - -这是一个Python版本的CSM-TCP-Router客户端API,实现了与LabVIEW版本相同的功能,可以连接到CSM-TCP-Router服务器,发送命令并接收响应。 - -## 功能特性 - -- 与CSM-TCP-Router服务器建立TCP连接 -- 发送同步命令并等待回复 -- 发送异步命令 -- 发送无返回异步命令 -- Ping服务器 -- 订阅状态变化通知 -- 等待服务器可用 -- 完整的错误处理和线程安全设计 - -## 文件结构 - -- `tcp_router_client.py`: 主要的客户端API类实现 -- `example_usage.py`: 使用示例代码 -- `README.md`: 使用说明文档 - -## 使用方法 - -### 基本连接 - -```python -from tcp_router_client import TcpRouterClient - -# 创建客户端实例 -client = TcpRouterClient() - -# 连接到服务器 -if client.connect("localhost", 9999): - print("连接成功") - # 执行操作... - - # 断开连接 - client.disconnect() -else: - print("连接失败") -``` - -### 发送同步命令 - -```python -# 发送命令并等待回复 -response = client.send_message_and_wait_for_reply("List") -print(f"回复: {response}") -``` - -### 发送异步命令 - -```python -# 发送异步命令 -async_cmd = "API: Read Channels -> AI" -client.post_message(async_cmd) - -# 发送无返回异步命令 -no_rep_cmd = "API: Refresh ->| System" -client.post_no_rep_message(no_rep_cmd) -``` - -### 订阅状态变化 - -```python -# 定义状态变化回调函数 -def status_callback(status_data): - print(f"收到状态更新: {status_data}") - -# 注册状态变化通知 -client.register_status_change("Status", "AI", status_callback) - -# 取消订阅 -client.unregister_status_change("Status", "AI") -``` - -### 等待服务器可用 - -```python -# 等待服务器可用,最多等待30秒 -success = client.wait_for_server("localhost", 9999, timeout=30) -if success: - print("服务器已可用") - client.connect("localhost", 9999) -``` - -### Ping服务器 - -```python -# Ping服务器,检查连接状态 -success, elapsed = client.ping() -if success: - print(f"Ping成功,延迟: {elapsed*1000:.2f}ms") -``` - -## 通讯协议 - -Python客户端实现了与CSM-TCP-Router相同的通讯协议,数据包格式如下: - -``` -| 数据长度(4B) | 版本(1B) | TYPE(1B) | FLAG1(1B) | FLAG2(1B) | 文本数据 | -╰─────────────────────────── 包头 ──────────────────────────╯╰──── 数据长度字范围 ────╯ -``` - -支持的数据包类型: -- 信息数据包(info) - `0x00` -- 错误数据包(error) - `0x01` -- 指令数据包(cmd) - `0x02` -- 同步响应数据包(resp) - `0x03` -- 异步响应数据包(async-resp) - `0x04` -- 订阅返回数据包(status) - `0x05` - -## 支持的指令集 - -### 1. CSM 消息指令集 -由原有基于CSM开发的代码定义,支持: -- 同步消息 (-@) -- 异步消息 (->) -- 无返回异步消息 (->|) - -### 2. CSM-TCP-Router 指令集 -- `List` - 列出所有的CSM模块 -- `List API`: 列出指定模块的所有API -- `List State`: 列出指定模块的所有CSM状态 -- `Help` - 显示模块的帮助文件 -- `Refresh lvcsm`: 刷新缓存文件 -- `Ping` - 测试服务器连接 - -## 注意事项 - -1. 确保在使用完客户端后调用`disconnect()`或`release()`方法释放资源 -2. 回调函数将在接收线程中执行,避免在回调函数中执行长时间阻塞操作 -3. 当网络连接异常断开时,客户端会自动将`connected`标志设为False -4. 对于频繁发送消息的场景,建议使用连接池或重用同一个客户端实例 - -## 示例程序 - -请参考`example_usage.py`文件,其中包含了详细的使用示例。 - -## 依赖项 - -本客户端API仅使用Python标准库,无需安装额外依赖: -- `socket`: 用于TCP通信 -- `struct`: 用于解析数据包 -- `threading`: 用于多线程处理 -- `queue`: 用于线程间通信 -- `json`: 用于数据序列化(预留) -- `time`: 用于超时和延时 -- `enum`: 用于定义数据包类型枚举 - -## 与LabVIEW版本对比 - -此Python版本实现了LabVIEW版本ClientAPI的所有核心功能: -- `Obtain.vi` -> `__init__()` 和 `obtain()` -- `Release.vi` -> `release()` -- `Send Message and Wait for Reply.vi` -> `send_message_and_wait_for_reply()` -- `Post Message.vi` -> `post_message()` -- `Post No-Rep Message.vi` -> `post_no_rep_message()` -- `Ping.vi` -> `ping()` -- `Register Status Change.vi` -> `register_status_change()` -- `Unregister Status Change.vi` -> `unregister_status_change()` -- `Wait for Server.vi` -> `wait_for_server()` - -## 版本历史 - -- v1.0.0: 初始版本,实现基本功能 \ No newline at end of file diff --git a/SDK/PythonClientAPI/example_usage.py b/SDK/PythonClientAPI/example_usage.py deleted file mode 100644 index 5569d7c..0000000 --- a/SDK/PythonClientAPI/example_usage.py +++ /dev/null @@ -1,103 +0,0 @@ -import time -from tcp_router_client import TcpRouterClient - -"""CSM-TCP-Router Python客户端API使用示例""" - -def main(): - # 创建客户端实例 - client = TcpRouterClient() - - print("CSM-TCP-Router Python客户端API示例") - print("================================") - - # 示例1: 连接到服务器 - print("\n示例1: 连接到服务器") - if client.connect("localhost", 30007): - print("✅ 成功连接到服务器") - else: - print("❌ 连接服务器失败,请确保服务器已启动") - return - - # 示例2: Ping服务器 - print("\n示例2: Ping服务器") - success, elapsed = client.ping(timeout=2) - if success: - print(f"✅ Ping成功,延迟: {elapsed*1000:.2f}ms") - else: - print("❌ Ping失败") - - # 示例3: 发送同步命令并等待回复 - print("\n示例3: 发送同步命令并等待回复") - # 列出所有CSM模块 - response = client.send_message_and_wait_for_reply("List") - print(f"命令: List") - print(f"回复: {response}") - - # 列出特定模块的API - # 注意:这里假设存在名为"AI"的模块,如果不存在,您需要修改为实际存在的模块名 - module_name = "AI" - response = client.send_message_and_wait_for_reply(f"List API {module_name}") - print(f"\n命令: List API {module_name}") - print(f"回复: {response}") - - # 示例4: 发送异步命令 - print("\n示例4: 发送异步命令") - # 注意:这里的命令需要根据实际的CSM模块进行调整 - async_cmd = "API: Read Channels -> AI" - success = client.post_message(async_cmd) - print(f"命令: {async_cmd}") - print(f"发送结果: {'✅ 成功' if success else '❌ 失败'}") - - # 示例5: 发送无返回异步命令 - print("\n示例5: 发送无返回异步命令") - # 注意:这里的命令需要根据实际的CSM模块进行调整 - no_rep_cmd = "API: Refresh ->| System" - success = client.post_no_rep_message(no_rep_cmd) - print(f"命令: {no_rep_cmd}") - print(f"发送结果: {'✅ 成功' if success else '❌ 失败'}") - - # 示例6: 订阅状态变化 - print("\n示例6: 订阅状态变化") - # 状态变化回调函数 - def status_callback(status_data): - print(f"📢 收到状态更新: {status_data}") - - # 注册状态变化通知 - # 注意:这里假设存在名为"AI"的模块和"Status"状态,如果不存在,您需要修改为实际存在的模块名和状态名 - success = client.register_status_change("Status", "AI", status_callback) - print(f"订阅 'Status@AI' 结果: {'✅ 成功' if success else '❌ 失败'}") - - # 保持连接一段时间,等待状态更新 - print("\n等待5秒,观察状态更新...") - time.sleep(5) - - # 取消订阅 - success = client.unregister_status_change("Status", "AI") - print(f"取消订阅 'Status@AI' 结果: {'✅ 成功' if success else '❌ 失败'}") - - # 示例7: 等待服务器可用 - print("\n示例7: 等待服务器可用(演示用,当前已连接)") - # 断开当前连接 - client.disconnect() - print("已断开连接") - - # 等待服务器可用 - print("等待服务器可用,最多等待10秒...") - # 注意:如果服务器未运行,这个调用将会超时 - success = client.wait_for_server("localhost", 9999, timeout=10) - print(f"服务器可用检查结果: {'✅ 服务器可用' if success else '❌ 服务器不可用'}") - - # 重新连接(如果服务器可用) - if success: - client.connect("localhost", 9999) - print("✅ 已重新连接到服务器") - - # 示例8: 释放资源 - print("\n示例8: 释放资源") - client.release() - print("✅ 客户端资源已释放") - - print("\n示例执行完毕") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/SDK/PythonClientAPI/tcp_router_client.py b/SDK/PythonClientAPI/tcp_router_client.py deleted file mode 100644 index 0780e4a..0000000 --- a/SDK/PythonClientAPI/tcp_router_client.py +++ /dev/null @@ -1,259 +0,0 @@ -import socket -import struct -import threading -import queue -import json -import time -from enum import Enum - -class PacketType(Enum): - INFO = 0x00 - ERROR = 0x01 - CMD = 0x02 - RESP = 0x03 - ASYNC_RESP = 0x04 - STATUS = 0x05 - -class TcpRouterClient: - def __init__(self): - self.socket = None - self.connected = False - self.host = "" - self.port = 0 - self.recv_thread = None - self.stop_event = threading.Event() - self.response_queue = queue.Queue() - self.async_response_callbacks = {} - self.status_callbacks = {} - self.async_response_queue = queue.Queue() - self.status_queue = queue.Queue() - self.lock = threading.Lock() - - def connect(self, host, port, timeout=5): - """连接到CSM-TCP-Router服务器""" - try: - self.host = host - self.port = port - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.socket.settimeout(timeout) - self.socket.connect((host, port)) - self.connected = True - self.stop_event.clear() - self.recv_thread = threading.Thread(target=self._receive_thread) - self.recv_thread.daemon = True - self.recv_thread.start() - return True - except Exception as e: - print(f"连接失败: {e}") - self.connected = False - return False - - def disconnect(self): - """断开与服务器的连接""" - if self.connected: - self.stop_event.set() - try: - if self.socket: - self.socket.close() - except: - pass - self.connected = False - if self.recv_thread: - self.recv_thread.join(timeout=2) - - def send_message(self, message, packet_type, flag1=0, flag2=0): - """发送消息到服务器""" - if not self.connected: - print("未连接到服务器") - return False - - try: - # 确保消息为字节类型 - if isinstance(message, str): - message_bytes = message.encode() # 使用系统默认编码 - else: - message_bytes = message - - # 计算数据长度 - data_len = len(message_bytes) - # 构建数据包 - header = struct.pack('!IBBBB', data_len, 0x01, packet_type.value, flag1, flag2) - # 发送数据包 - with self.lock: - self.socket.sendall(header) - self.socket.sendall(message_bytes) - return True - except Exception as e: - print(f"发送消息失败: {e}") - self.connected = False - return False - - def send_message_and_wait_for_reply(self, message, timeout=5): - """发送消息并等待回复""" - if not self.send_message(message, PacketType.CMD): - return None - - try: - response = self.response_queue.get(timeout=timeout) - return response - except queue.Empty: - print("等待回复超时") - return None - - def post_message(self, message): - """发送异步消息""" - return self.send_message(message, PacketType.CMD) - - def post_no_rep_message(self, message): - """发送无返回异步消息""" - return self.send_message(message, PacketType.CMD) - - def ping(self, timeout=2): - """Ping服务器""" - start_time = time.time() - response = self.send_message_and_wait_for_reply("Ping", timeout=timeout) - if response: - elapsed = time.time() - start_time - return True, elapsed - return False, 0 - - def register_status_change(self, status_name, module_name, callback=None): - """注册状态变化通知""" - cmd = f"{status_name}@{module_name} ->" - success = self.send_message(cmd, PacketType.CMD) - if success and callback: - with self.lock: - self.status_callbacks[(status_name, module_name)] = callback - return success - - def unregister_status_change(self, status_name, module_name): - """取消注册状态变化通知""" - cmd = f"{status_name}@{module_name} ->" - success = self.send_message(cmd, PacketType.CMD) - if success: - with self.lock: - key = (status_name, module_name) - if key in self.status_callbacks: - del self.status_callbacks[key] - return success - - def wait_for_server(self, host, port, timeout=30): - """等待服务器可用""" - start_time = time.time() - while time.time() - start_time < timeout: - if self.connect(host, port, timeout=1): - self.disconnect() - return True - time.sleep(0.5) - return False - - def _receive_thread(self): - """接收线程,处理来自服务器的消息""" - while not self.stop_event.is_set(): - try: - # 接收包头 - header = self._receive_all(8) # 4+1+1+1+1=8字节 - if not header: - break - - # 解析包头 - data_len, version, packet_type, flag1, flag2 = struct.unpack('!IBBBB', header) - - # 接收数据(保持字节类型) - data = self._receive_all(data_len) - - # 处理不同类型的数据包 - if packet_type == PacketType.RESP.value: - self.response_queue.put(data) - elif packet_type == PacketType.ASYNC_RESP.value: - self._handle_async_response(data) - elif packet_type == PacketType.STATUS.value: - self._handle_status(data) - elif packet_type == PacketType.INFO.value: - print(f"[INFO] {data}") - elif packet_type == PacketType.ERROR.value: - print(f"[ERROR] {data}") - - except Exception as e: - if not self.stop_event.is_set(): - print(f"接收数据错误: {e}") - break - - # 线程结束,标记断开连接 - self.connected = False - - def _receive_all(self, size): - """接收指定大小的数据""" - data = b'' - while len(data) < size: - packet = self.socket.recv(size - len(data)) - if not packet: - return b'' - data += packet - return data - - def _handle_async_response(self, data): - """处理异步响应""" - self.async_response_queue.put(data) - # 这里可以根据需要调用注册的回调函数 - # 例如,可以解析data中的信息,找到对应的回调函数并调用 - - def _handle_status(self, data): - """处理状态更新""" - self.status_queue.put(data) - # 解析状态数据并调用相应的回调函数 - # 简化处理,实际应用中可能需要更复杂的解析逻辑 - parts = data.split(' >> ', 1) - if len(parts) == 2: - status_info, _ = parts - status_parts = status_info.split(' <- ', 1) - if len(status_parts) == 2: - status_name, module_name = status_parts - with self.lock: - callback = self.status_callbacks.get((status_name, module_name)) - if callback: - callback(data) - - def obtain(self): - """获取客户端实例(模拟LabVIEW的Obtain.vi)""" - # 在Python中,这个方法可以简单返回自身实例 - return self - - def release(self): - """释放客户端资源(模拟LabVIEW的Release.vi)""" - self.disconnect() - -# 示例用法 -if __name__ == "__main__": - client = TcpRouterClient() - - # 连接服务器 - if client.connect("localhost", 30007): - print("连接成功") - - # 发送Ping命令 - success, elapsed = client.ping() - if success: - print(f"Ping成功,延迟: {elapsed*1000:.2f}ms") - - # 发送命令并等待回复 - response = client.send_message_and_wait_for_reply("List") - print(f"List命令回复: {response}") - - # 订阅状态变化 - def status_callback(data): - print(f"收到状态更新: {data}") - - client.register_status_change("Status", "AI", status_callback) - - # 保持连接一段时间 - time.sleep(5) - - # 取消订阅 - client.unregister_status_change("Status", "AI") - - # 断开连接 - client.disconnect() - print("已断开连接") - else: - print("连接失败") \ No newline at end of file diff --git a/SDK/c/.gitignore b/SDK/c/.gitignore new file mode 100644 index 0000000..f5ae8e1 --- /dev/null +++ b/SDK/c/.gitignore @@ -0,0 +1,34 @@ +# CMake / build artefacts +build/ +build-*/ +out/ +cmake-build-*/ + +# Visual Studio +vs2026/build/ +*.user +*.suo +*.sdf +*.opensdf +*.aps +*.ipch +.vs/ +[Dd]ebug/ +[Rr]elease/ +[Xx]64/ +[Ww]in32/ +*.obj +*.lib +*.dll +*.exe +*.pdb +*.idb +*.ilk +*.exp + +# Compiled objects +*.o +*.a +*.so +*.so.* +*.dylib diff --git a/SDK/c/CHANGELOG.md b/SDK/c/CHANGELOG.md new file mode 100644 index 0000000..fa92df1 --- /dev/null +++ b/SDK/c/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +All notable changes to the C `csm-tcp-router-client` SDK will be +documented in this file. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-04-27 + +Initial public release of the C SDK. + +### Added + +- Cross-platform TCP client implementation (Windows + POSIX). + - Single source file `src/csm_tcp_router_client.c`. + - Single header `include/csm_tcp_router_client.h`. + - Background receive thread with thread-safe public API. +- CSM-TCP-Router protocol v0 codec (`csm_encode_packet`, + `csm_decode_header`, `csm_parse_packet`). +- Synchronous command API (`csm_client_send_and_wait`). +- Asynchronous command API (`csm_client_post`, + `csm_client_post_no_reply`) with callback + polling-queue delivery. +- Status / interrupt subscription API + (`csm_client_subscribe_status` / `csm_client_unsubscribe_status`, + `csm_client_poll_status`). +- Router management helpers: `csm_client_list_modules`, + `csm_client_list_api`, `csm_client_list_states`, `csm_client_help`. +- Connection utilities: `csm_client_ping`, + `csm_client_wait_for_server`, `csm_client_is_connected`. +- Server error inspection via `csm_client_last_server_error`. +- Examples: `examples/basic_usage.c`, `examples/subscribe_status.c`. +- Test suite using an in-process `MockServer` fixture + (`tests/mock_server.[ch]`) and a tiny custom test harness + (`tests/test_harness.h` + `tests/test_main.c`): + protocol codec tests, client-lifecycle tests, end-to-end integration + tests. +- CMake build (`CMakeLists.txt`) with options for tests, examples, and + shared-library output; `ctest` integration. +- Visual Studio 2026 (toolset `v144`) solution + projects under + `vs2026/` for IDE-driven build & test on Windows. +- GitHub Actions CI workflow (`.github/workflows/C_SDK.yml`) building + and running tests on Ubuntu, Windows and macOS. diff --git a/SDK/c/CMakeLists.txt b/SDK/c/CMakeLists.txt new file mode 100644 index 0000000..73033bb --- /dev/null +++ b/SDK/c/CMakeLists.txt @@ -0,0 +1,102 @@ +# CMake build for the csm-tcp-router-client C SDK. +# +# Usage: +# mkdir build && cd build +# cmake .. +# cmake --build . --config Release +# ctest --output-on-failure -C Release +# +# Options: +# -DCSM_BUILD_TESTS=ON|OFF (default ON) +# -DCSM_BUILD_EXAMPLES=ON|OFF (default ON) +# -DCSM_BUILD_SHARED=ON|OFF (default OFF; ON builds a shared library) +cmake_minimum_required(VERSION 3.15) +project(csm_tcp_router_client + VERSION 0.1.0 + DESCRIPTION "C client SDK for CSM-TCP-Router" + LANGUAGES C) + +option(CSM_BUILD_TESTS "Build the test suite" ON) +option(CSM_BUILD_EXAMPLES "Build the example apps" ON) +option(CSM_BUILD_SHARED "Build as a shared library" OFF) + +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) + +if(MSVC) + add_compile_options(/W4 /D_CRT_SECURE_NO_WARNINGS) +else() + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +# ------------------------------------------------------------------------- +# Library +# ------------------------------------------------------------------------- +if(CSM_BUILD_SHARED) + add_library(csm_tcp_router_client SHARED src/csm_tcp_router_client.c) + target_compile_definitions(csm_tcp_router_client + PRIVATE CSM_BUILD_LIBRARY CSM_BUILD_SHARED + PUBLIC CSM_BUILD_SHARED) +else() + add_library(csm_tcp_router_client STATIC src/csm_tcp_router_client.c) +endif() + +target_include_directories(csm_tcp_router_client + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + +set_target_properties(csm_tcp_router_client PROPERTIES + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR}) + +# Platform-specific link libraries. +if(WIN32) + target_link_libraries(csm_tcp_router_client PUBLIC ws2_32) +else() + find_package(Threads REQUIRED) + target_link_libraries(csm_tcp_router_client PUBLIC Threads::Threads) +endif() + +# ------------------------------------------------------------------------- +# Tests +# ------------------------------------------------------------------------- +if(CSM_BUILD_TESTS) + enable_testing() + add_executable(csm_tcp_router_client_tests + tests/mock_server.c + tests/test_protocol.c + tests/test_client.c + tests/test_integration.c + tests/test_main.c) + target_include_directories(csm_tcp_router_client_tests + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/tests) + target_link_libraries(csm_tcp_router_client_tests + PRIVATE csm_tcp_router_client) + if(WIN32) + target_link_libraries(csm_tcp_router_client_tests PRIVATE ws2_32) + endif() + add_test(NAME csm_tcp_router_client_tests + COMMAND csm_tcp_router_client_tests) +endif() + +# ------------------------------------------------------------------------- +# Examples +# ------------------------------------------------------------------------- +if(CSM_BUILD_EXAMPLES) + add_executable(basic_usage examples/basic_usage.c) + add_executable(subscribe_status examples/subscribe_status.c) + add_executable(client_console examples/client_console.c) + target_link_libraries(basic_usage PRIVATE csm_tcp_router_client) + target_link_libraries(subscribe_status PRIVATE csm_tcp_router_client) + target_link_libraries(client_console PRIVATE csm_tcp_router_client) +endif() + +# ------------------------------------------------------------------------- +# Install rules (header + library) +# ------------------------------------------------------------------------- +install(TARGETS csm_tcp_router_client + RUNTIME DESTINATION bin + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib) +install(FILES include/csm_tcp_router_client.h DESTINATION include) diff --git a/SDK/c/LICENSE b/SDK/c/LICENSE new file mode 100644 index 0000000..78e1cd2 --- /dev/null +++ b/SDK/c/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NEVSTOP-LAB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SDK/c/README.md b/SDK/c/README.md new file mode 100644 index 0000000..5a93a24 --- /dev/null +++ b/SDK/c/README.md @@ -0,0 +1,258 @@ +# csm-tcp-router-client (C SDK) + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![CI](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/C_SDK.yml/badge.svg)](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/C_SDK.yml) + +C client SDK for the [CSM-TCP-Router](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App) LabVIEW server. + +CSM-TCP-Router exposes a LabVIEW [Communicable State Machine (CSM)](https://github.com/NEVSTOP-LAB/Communicable-State-Machine) application over TCP so that any TCP client — including native C/C++ programs, embedded devices, test harnesses, or CI pipelines — can send commands and receive responses without touching the LabVIEW code. + +> 📖 [中文文档 README.zh-cn.md](README.zh-cn.md) + +--- + +## Features + +- **Synchronous commands** (`-@`) – `csm_client_send_and_wait()` blocks until the server returns the response. +- **Asynchronous commands** (`->`) – `csm_client_post()` waits for the `cmd-resp` handshake; the eventual response is delivered via callback or polling queue. +- **No-reply commands** (`->|`) – `csm_client_post_no_reply()` waits for the `cmd-resp` handshake; no further response expected. +- **Status subscriptions** – `csm_client_subscribe_status()` / `csm_client_unsubscribe_status()` with optional callback or polling queue. +- **Router management helpers** – `csm_client_list_modules()`, `csm_client_list_api()`, `csm_client_list_states()`, `csm_client_help()`. +- **Connection utilities** – `csm_client_wait_for_server()` for polling during app startup. +- **Thread-safe client** – every public function is safe to call from multiple threads concurrently. +- **Multi-platform** – Windows (Winsock2 + Win32 threads) and POSIX (BSD sockets + pthreads); single source file. +- **Zero runtime dependencies** – C99 standard library + the OS sockets/threading APIs only. + +--- + +## Layout + +``` +SDK/c/ +├── include/ +│ └── csm_tcp_router_client.h # public API +├── src/ +│ └── csm_tcp_router_client.c # cross-platform implementation +├── examples/ +│ ├── basic_usage.c # mirrors examples/basic_usage.py +│ └── subscribe_status.c # mirrors examples/subscribe_status.py +├── tests/ +│ ├── test_harness.h # tiny in-process test harness +│ ├── mock_server.[ch] # in-process MockServer fixture +│ ├── test_protocol.c # codec unit tests +│ ├── test_client.c # client-lifecycle unit tests +│ ├── test_integration.c # end-to-end tests via MockServer +│ └── test_main.c # runner / TESTS table +├── vs2026/ +│ ├── csm_tcp_router_client.sln +│ ├── csm_tcp_router_client/ # static-library project +│ └── csm_tcp_router_client.tests/ # test-executable project +├── CMakeLists.txt # cross-platform CMake build +├── CHANGELOG.md +├── LICENSE +├── README.md +└── README.zh-cn.md +``` + +This mirrors the layout of the Python SDK at `SDK/python/`. + +--- + +## Building + +### CMake (Linux / macOS / Windows) + +```bash +cd SDK/c +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -j +ctest --test-dir build --output-on-failure -C Release +``` + +CMake options: + +| Option | Default | Description | +|-------------------------|---------|--------------------------------------| +| `CSM_BUILD_TESTS` | `ON` | Build the test executable. | +| `CSM_BUILD_EXAMPLES` | `ON` | Build the example apps. | +| `CSM_BUILD_SHARED` | `OFF` | Build a shared library (DLL/.so). | + +### Visual Studio 2026 + +Open `SDK/c/vs2026/csm_tcp_router_client.sln` in Visual Studio 2026 and +build (Ctrl+Shift+B). The solution provides Debug/Release × Win32/x64 +configurations using the `v144` platform toolset. Two projects are +included: + +- `csm_tcp_router_client` – static library +- `csm_tcp_router_client.tests` – console test executable (run it + directly to execute all unit + integration tests; exit code 0 on + success). + +See [`vs2026/README.md`](vs2026/README.md) for details. + +--- + +## Quickstart + +```c +#include "csm_tcp_router_client.h" +#include + +int main(void) { + csm_client_t *c = csm_client_create(); + if (csm_client_connect(c, "localhost", 30007, 5000) != CSM_OK) { + fprintf(stderr, "Connect failed\n"); + csm_client_destroy(c); + return 1; + } + + char *modules = NULL; + if (csm_client_list_modules(c, &modules, 5000) == CSM_OK) { + printf("Modules:\n%s\n", modules); + csm_string_free(modules); + } + + csm_command_response_t resp = {0}; + if (csm_client_send_and_wait(c, "API: Read -@ DAQmx", 5000, &resp) == CSM_OK) { + printf("Response: %s\n", (char *)resp.raw); + } + csm_command_response_dispose(&resp); + + double ms = 0; + if (csm_client_ping(c, 2000, &ms) == CSM_OK) { + printf("Ping latency: %.1f ms\n", ms); + } + + csm_client_disconnect(c); + csm_client_destroy(c); + return 0; +} +``` + +--- + +## Protocol + +The SDK implements the CSM-TCP-Router **protocol v0**. + +``` +| Data Length (4B) | Version (1B) | TYPE (1B) | FLAG1 (1B) | FLAG2 (1B) | Text Data | +╰────────────────────────── Header (8B) ─────────────────────────────╯ +``` + +| TYPE byte | Constant | Direction | Description | +|-----------|---------------------|----------------|------------------------------------------------| +| `0x00` | `CSM_PT_INFO` | Server → Client| Welcome / goodbye informational message | +| `0x01` | `CSM_PT_ERROR` | Server → Client| CSM error: `[Error: ] ` | +| `0x02` | `CSM_PT_CMD` | Client → Server| Command string | +| `0x03` | `CSM_PT_CMD_RESP` | Server → Client| Handshake ACK for async / subscribe commands | +| `0x04` | `CSM_PT_RESP` | Server → Client| Synchronous response payload | +| `0x05` | `CSM_PT_ASYNC_RESP` | Server → Client| Async response: ` <- ` | +| `0x06` | `CSM_PT_STATUS` | Server → Client| Status broadcast: ` >> <- ` | +| `0x07` | `CSM_PT_INTERRUPT` | Server → Client| Interrupt broadcast (same format as STATUS) | + +--- + +## API at a glance + +### Lifecycle + +| Function | Description | +|---|---| +| `csm_client_create()` | Allocate a new client. | +| `csm_client_destroy(c)` | Disconnect (if connected) and free resources. | +| `csm_client_connect(c, host, port, timeout_ms)` | Open a TCP connection and start the receive thread. | +| `csm_client_disconnect(c)` | Close the connection; safe even when not connected. | +| `csm_client_is_connected(c)` | Non-zero while connected. | +| `csm_client_wait_for_server(host, port, timeout_ms, retry_ms)` | Poll until the server is reachable. | + +### Commands + +| Function | Description | +|---|---| +| `csm_client_send_and_wait(c, cmd, timeout, &resp)` | Synchronous command (`-@`). | +| `csm_client_post(c, cmd, timeout)` | Async command (`->`). | +| `csm_client_post_no_reply(c, cmd, timeout)` | No-reply async command (`->|`). | +| `csm_client_ping(c, timeout, &elapsed_ms)` | Round-trip latency check. | + +### Router management helpers + +| Function | Description | +|---|---| +| `csm_client_list_modules(c, &out_text, timeout)` | `List` command. | +| `csm_client_list_api(c, module, &out_text, timeout)` | `List API `. | +| `csm_client_list_states(c, module, &out_text, timeout)` | `List State `. | +| `csm_client_help(c, module, &out_text, timeout)` | `Help `. | + +Free `*out_text` with `csm_string_free()`. + +### Subscriptions + +| Function | Description | +|---|---| +| `csm_client_subscribe_status(c, status, module, cb, ud, timeout)` | Subscribe; callback invoked from receive thread. | +| `csm_client_unsubscribe_status(c, status, module, timeout)` | Cancel a subscription. | +| `csm_client_register_async_callback(c, cmd, cb, ud)` | Register callback for `ASYNC_RESP` packets. | +| `csm_client_unregister_async_callback(c, cmd)` | Remove a callback. | + +### Polling queues (alternative to callbacks) + +| Function | Description | +|---|---| +| `csm_client_poll_status(c, &out_notif, timeout)` | Block until next STATUS / INTERRUPT. | +| `csm_client_poll_async_response(c, &out_resp, timeout)` | Block until next `ASYNC_RESP`. | + +--- + +## Result codes + +All public functions return a `csm_result_t`: + +| Code | Meaning | +|-----------------------|-------------------------------------------------| +| `CSM_OK` | Operation succeeded. | +| `CSM_ERR_INVALID` | Invalid argument or NULL pointer. | +| `CSM_ERR_CONNECTION` | Connection failed or was lost. | +| `CSM_ERR_TIMEOUT` | Operation exceeded its timeout. | +| `CSM_ERR_PROTOCOL` | Invalid / malformed protocol frame. | +| `CSM_ERR_SERVER` | Server returned an `ERROR` packet (see below). | +| `CSM_ERR_NOMEM` | Memory allocation failure. | +| `CSM_ERR_STATE` | Operation invalid in the current state. | +| `CSM_ERR_IO` | Underlying socket / OS I/O error. | + +After a `CSM_ERR_SERVER`, retrieve the error code/message via: + +```c +csm_server_error_t err; +csm_client_last_server_error(c, &err); +fprintf(stderr, "[%s] %s\n", err.code, err.message); +``` + +`csm_result_str(code)` returns a static, human-readable string. + +--- + +## Tests + +The test suite (`SDK/c/tests/`) uses a tiny in-process harness and an +embedded `MockServer` (see `tests/mock_server.h`) that emulates the +LabVIEW CSM-TCP-Router on `127.0.0.1`. The same tests run on Linux, +macOS, and Windows. + +```bash +cmake --build build -j +ctest --test-dir build --output-on-failure +``` + +Or run the executable directly to see per-test progress: + +```bash +./build/csm_tcp_router_client_tests +``` + +--- + +## License + +[MIT](LICENSE) — © NEVSTOP-LAB diff --git a/SDK/c/README.zh-cn.md b/SDK/c/README.zh-cn.md new file mode 100644 index 0000000..2d2e02b --- /dev/null +++ b/SDK/c/README.zh-cn.md @@ -0,0 +1,250 @@ +# csm-tcp-router-client (C SDK) + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![CI](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/C_SDK.yml/badge.svg)](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/C_SDK.yml) + +[CSM-TCP-Router](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App) LabVIEW 服务端的 C 语言客户端 SDK。 + +CSM-TCP-Router 通过 TCP 暴露一个基于 LabVIEW [Communicable State Machine (CSM)](https://github.com/NEVSTOP-LAB/Communicable-State-Machine) 框架的应用程序,使任何 TCP 客户端 —— 包括原生 C/C++ 程序、嵌入式设备、测试夹具或 CI 流水线 —— 都可以发送命令并接收响应,无需修改任何 LabVIEW 代码。 + +> 📖 [English README.md](README.md) + +--- + +## 特性 + +- **同步命令** (`-@`) – `csm_client_send_and_wait()` 阻塞直到服务器返回响应。 +- **异步命令** (`->`) – `csm_client_post()` 等待 `cmd-resp` 握手;最终响应通过回调或轮询队列送达。 +- **无回复命令** (`->|`) – `csm_client_post_no_reply()` 等待 `cmd-resp` 握手;不再有后续响应。 +- **状态订阅** – `csm_client_subscribe_status()` / `csm_client_unsubscribe_status()`,可选回调或轮询队列。 +- **路由器管理辅助函数** – `csm_client_list_modules()`、`csm_client_list_api()`、`csm_client_list_states()`、`csm_client_help()`。 +- **连接工具** – 应用启动期间使用 `csm_client_wait_for_server()` 进行轮询。 +- **线程安全的客户端** – 所有公开函数均可由多线程并发调用。 +- **多平台移植** – Windows(Winsock2 + Win32 线程)和 POSIX(BSD sockets + pthreads);单源文件实现。 +- **无运行时依赖** – 仅依赖 C99 标准库与操作系统的 sockets/线程 API。 + +--- + +## 目录结构 + +``` +SDK/c/ +├── include/ +│ └── csm_tcp_router_client.h # 公开 API 头文件 +├── src/ +│ └── csm_tcp_router_client.c # 跨平台实现 +├── examples/ +│ ├── basic_usage.c # 对应 examples/basic_usage.py +│ └── subscribe_status.c # 对应 examples/subscribe_status.py +├── tests/ +│ ├── test_harness.h # 进程内极简测试框架 +│ ├── mock_server.[ch] # 进程内 MockServer 测试夹具 +│ ├── test_protocol.c # 协议编解码单元测试 +│ ├── test_client.c # 客户端生命周期单元测试 +│ ├── test_integration.c # 通过 MockServer 的端到端测试 +│ └── test_main.c # 测试运行器/TESTS 表 +├── vs2026/ +│ ├── csm_tcp_router_client.sln +│ ├── csm_tcp_router_client/ # 静态库工程 +│ └── csm_tcp_router_client.tests/ # 测试可执行工程 +├── CMakeLists.txt # 跨平台 CMake 构建 +├── CHANGELOG.md +├── LICENSE +├── README.md +└── README.zh-cn.md +``` + +整体结构与 `SDK/python/` 下的 Python SDK 保持一致。 + +--- + +## 编译 + +### CMake(Linux / macOS / Windows) + +```bash +cd SDK/c +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -j +ctest --test-dir build --output-on-failure -C Release +``` + +CMake 选项: + +| 选项 | 默认值 | 说明 | +|-------------------------|---------|-----------------------------------| +| `CSM_BUILD_TESTS` | `ON` | 是否构建测试可执行文件。 | +| `CSM_BUILD_EXAMPLES` | `ON` | 是否构建示例程序。 | +| `CSM_BUILD_SHARED` | `OFF` | 是否编译为动态库(DLL/.so)。 | + +### Visual Studio 2026 + +打开 `SDK/c/vs2026/csm_tcp_router_client.sln`,使用 Visual Studio 2026 直接构建(Ctrl+Shift+B)。该解决方案提供 Debug/Release × Win32/x64 四种配置,平台工具集为 `v144`,包含两个工程: + +- `csm_tcp_router_client` – 静态库 +- `csm_tcp_router_client.tests` – 控制台测试可执行文件(直接运行即可执行所有单元 + 集成测试,退出码为 0 即所有测试通过)。 + +详见 [`vs2026/README.md`](vs2026/README.md)。 + +--- + +## 快速开始 + +```c +#include "csm_tcp_router_client.h" +#include + +int main(void) { + csm_client_t *c = csm_client_create(); + if (csm_client_connect(c, "localhost", 30007, 5000) != CSM_OK) { + fprintf(stderr, "连接失败\n"); + csm_client_destroy(c); + return 1; + } + + char *modules = NULL; + if (csm_client_list_modules(c, &modules, 5000) == CSM_OK) { + printf("已加载模块:\n%s\n", modules); + csm_string_free(modules); + } + + csm_command_response_t resp = {0}; + if (csm_client_send_and_wait(c, "API: Read -@ DAQmx", 5000, &resp) == CSM_OK) { + printf("响应: %s\n", (char *)resp.raw); + } + csm_command_response_dispose(&resp); + + double ms = 0; + if (csm_client_ping(c, 2000, &ms) == CSM_OK) { + printf("Ping 延迟: %.1f ms\n", ms); + } + + csm_client_disconnect(c); + csm_client_destroy(c); + return 0; +} +``` + +--- + +## 协议 + +SDK 实现 CSM-TCP-Router **协议 v0**: + +``` +| 数据长度 (4B) | 版本 (1B) | TYPE (1B) | FLAG1 (1B) | FLAG2 (1B) | 文本数据 | +╰────────────────────────── 包头 (8B) ─────────────────────────────╯ +``` + +| TYPE 字节 | 常量 | 方向 | 说明 | +|-----------|---------------------|----------------|----------------------------------------------------| +| `0x00` | `CSM_PT_INFO` | 服务器 → 客户端 | 欢迎/告别等信息消息 | +| `0x01` | `CSM_PT_ERROR` | 服务器 → 客户端 | CSM 错误:`[Error: ] ` | +| `0x02` | `CSM_PT_CMD` | 客户端 → 服务器 | 命令字符串 | +| `0x03` | `CSM_PT_CMD_RESP` | 服务器 → 客户端 | 异步/订阅命令的握手 ACK | +| `0x04` | `CSM_PT_RESP` | 服务器 → 客户端 | 同步响应负载 | +| `0x05` | `CSM_PT_ASYNC_RESP` | 服务器 → 客户端 | 异步响应:` <- ` | +| `0x06` | `CSM_PT_STATUS` | 服务器 → 客户端 | 状态广播:` >> <- ` | +| `0x07` | `CSM_PT_INTERRUPT` | 服务器 → 客户端 | 中断广播(与 STATUS 格式相同) | + +--- + +## API 速览 + +### 生命周期 + +| 函数 | 说明 | +|---|---| +| `csm_client_create()` | 分配新的客户端。 | +| `csm_client_destroy(c)` | 如已连接则断开并释放资源。 | +| `csm_client_connect(c, host, port, timeout_ms)` | 建立 TCP 连接并启动接收线程。 | +| `csm_client_disconnect(c)` | 关闭连接;未连接时调用也安全。 | +| `csm_client_is_connected(c)` | 已连接时返回非零值。 | +| `csm_client_wait_for_server(host, port, timeout_ms, retry_ms)` | 轮询直到服务器可达。 | + +### 命令 + +| 函数 | 说明 | +|---|---| +| `csm_client_send_and_wait(c, cmd, timeout, &resp)` | 同步命令 (`-@`)。 | +| `csm_client_post(c, cmd, timeout)` | 异步命令 (`->`)。 | +| `csm_client_post_no_reply(c, cmd, timeout)` | 无回复异步命令 (`->|`)。 | +| `csm_client_ping(c, timeout, &elapsed_ms)` | 往返延迟检测。 | + +### 路由器管理辅助函数 + +| 函数 | 说明 | +|---|---| +| `csm_client_list_modules(c, &out_text, timeout)` | `List` 命令。 | +| `csm_client_list_api(c, module, &out_text, timeout)` | `List API `。 | +| `csm_client_list_states(c, module, &out_text, timeout)` | `List State `。 | +| `csm_client_help(c, module, &out_text, timeout)` | `Help `。 | + +请使用 `csm_string_free()` 释放 `*out_text`。 + +### 订阅 + +| 函数 | 说明 | +|---|---| +| `csm_client_subscribe_status(c, status, module, cb, ud, timeout)` | 订阅;回调由接收线程调用。 | +| `csm_client_unsubscribe_status(c, status, module, timeout)` | 取消订阅。 | +| `csm_client_register_async_callback(c, cmd, cb, ud)` | 为 `ASYNC_RESP` 包注册回调。 | +| `csm_client_unregister_async_callback(c, cmd)` | 移除回调。 | + +### 轮询队列(回调的替代方式) + +| 函数 | 说明 | +|---|---| +| `csm_client_poll_status(c, &out_notif, timeout)` | 阻塞直到下一条 STATUS / INTERRUPT。 | +| `csm_client_poll_async_response(c, &out_resp, timeout)` | 阻塞直到下一条 `ASYNC_RESP`。 | + +--- + +## 返回值 + +所有公开函数均返回 `csm_result_t`: + +| 代码 | 含义 | +|-----------------------|-------------------------------------------------| +| `CSM_OK` | 操作成功。 | +| `CSM_ERR_INVALID` | 参数无效或为 NULL。 | +| `CSM_ERR_CONNECTION` | 连接失败或连接丢失。 | +| `CSM_ERR_TIMEOUT` | 操作超时。 | +| `CSM_ERR_PROTOCOL` | 协议帧无效或损坏。 | +| `CSM_ERR_SERVER` | 服务器返回了 `ERROR` 数据包(见下文)。 | +| `CSM_ERR_NOMEM` | 内存分配失败。 | +| `CSM_ERR_STATE` | 当前状态下操作无效。 | +| `CSM_ERR_IO` | 底层 socket / 操作系统 I/O 错误。 | + +收到 `CSM_ERR_SERVER` 后,可通过以下方式获取错误码与消息: + +```c +csm_server_error_t err; +csm_client_last_server_error(c, &err); +fprintf(stderr, "[%s] %s\n", err.code, err.message); +``` + +`csm_result_str(code)` 返回静态可读字符串。 + +--- + +## 测试 + +测试套件(`SDK/c/tests/`)使用进程内极简测试框架与内嵌的 `MockServer`(详见 `tests/mock_server.h`),后者在 `127.0.0.1` 上模拟 LabVIEW CSM-TCP-Router。同一套测试在 Linux、macOS 与 Windows 上均可运行。 + +```bash +cmake --build build -j +ctest --test-dir build --output-on-failure +``` + +也可直接运行可执行文件以查看每条测试的进度: + +```bash +./build/csm_tcp_router_client_tests +``` + +--- + +## 许可证 + +[MIT](LICENSE) — © NEVSTOP-LAB diff --git a/SDK/c/examples/basic_usage.c b/SDK/c/examples/basic_usage.c new file mode 100644 index 0000000..bb9331f --- /dev/null +++ b/SDK/c/examples/basic_usage.c @@ -0,0 +1,61 @@ +/* basic_usage.c - 演示连接、Ping、列出模块以及 + * 发送同步命令。对应 examples/basic_usage.py。 */ +#include "csm_tcp_router_client.h" + +#include +#include + +#define HOST "localhost" +#define PORT 30007 + +int main(void) { + /* 1. 等待服务器就绪(可选)。 */ + printf("Waiting for server ... "); + fflush(stdout); + csm_result_t r = csm_client_wait_for_server(HOST, PORT, 30000, 500); + if (r != CSM_OK) { + printf("TIMEOUT - server did not start within 30s.\n"); + return 1; + } + printf("ready.\n"); + + /* 2. 创建 + 连接。 */ + csm_client_t *c = csm_client_create(); + if (!c) { fprintf(stderr, "Out of memory\n"); return 1; } + + r = csm_client_connect(c, HOST, PORT, 5000); + if (r != CSM_OK) { + fprintf(stderr, "Connection failed: %s\n", csm_result_str(r)); + csm_client_destroy(c); + return 1; + } + printf("Connected to %s:%d\n", HOST, PORT); + + /* 3. Ping。 */ + double ms = 0; + r = csm_client_ping(c, 2000, &ms); + if (r == CSM_OK) printf("Ping OK latency=%.1f ms\n", ms); + else printf("Ping failed: %s\n", csm_result_str(r)); + + /* 4. 列出 CSM 模块。 */ + char *modules = NULL; + if (csm_client_list_modules(c, &modules, 5000) == CSM_OK) { + printf("\nLoaded modules:\n%s\n", modules); + csm_string_free(modules); + } + + /* 5. 发送同步命令(连接到真实模块后取消注释)。 + * + * csm_command_response_t resp = {0}; + * if (csm_client_send_and_wait(c, "API: Read -@ DAQmx", 5000, &resp) == CSM_OK) { + * printf("Sync response: %s\n", (char *)resp.raw); + * } + * csm_command_response_dispose(&resp); + */ + + /* 6. 断开连接并清理资源。 */ + csm_client_disconnect(c); + csm_client_destroy(c); + printf("Disconnected.\n"); + return 0; +} diff --git a/SDK/c/examples/client_console.c b/SDK/c/examples/client_console.c new file mode 100644 index 0000000..d40ef4f --- /dev/null +++ b/SDK/c/examples/client_console.c @@ -0,0 +1,265 @@ +/* client_console.c - 交互式客户端控制台示例。 + * + * 连接到正在运行的 CSM-TCP-Router 服务器,从 stdin 读取用户输入的 + * 命令,并通过 SDK 转发。同样的命令集、提示符和输出格式也在 + * Python(examples/client_console.py)和 C#(examples/ClientConsole) + * SDK 示例中实现,因此三种语言的行为一致。 + * + * 用法: + * client_console [host] [port] + */ +#include "csm_tcp_router_client.h" + +#include +#include +#include + +#define DEFAULT_HOST "localhost" +#define DEFAULT_PORT 30007 +#define LINE_BUFFER_SIZE 4096 + +static const char *HELP_TEXT = + "Available commands:\n" + " help Show this help text\n" + " quit / exit Disconnect and exit\n" + " ping Measure round-trip latency\n" + " list List CSM modules loaded on the server\n" + " api List the API of a module\n" + " state List the states of a module\n" + " mhelp Server-side Help for a module\n" + " send Send a synchronous command and print the response\n" + " post Send an asynchronous command (-> suffix)\n" + " nopost Send a no-reply asynchronous command (->|)\n" + " sub @ Subscribe to a status broadcast\n" + " unsub @ Unsubscribe from a status broadcast"; + +static void on_status(const csm_status_notification_t *n, void *ud) { + (void)ud; + printf("\n[STATUS] %s@%s: %s\n", + n->status_name ? n->status_name : "", + n->module_name ? n->module_name : "", + n->data ? n->data : ""); +} + +static void on_async(const csm_async_response_t *r, void *ud) { + (void)ud; + printf("\n[ASYNC] %s (cmd=%s)\n", + r->raw ? r->raw : "", + r->original_command ? r->original_command : ""); +} + +/* 修剪 *s* 的前后空白字符(包括换行)。原地修改。返回 *s*。 */ +static char *trim(char *s) { + if (!s) return s; + char *end; + while (*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n') s++; + end = s + strlen(s); + while (end > s && (end[-1] == ' ' || end[-1] == '\t' || + end[-1] == '\r' || end[-1] == '\n')) { + end--; + } + *end = '\0'; + return s; +} + +/* 不区分大小写地比较两个 NUL 终止字符串。 */ +static int ieq(const char *a, const char *b) { + while (*a && *b) { + char ca = *a, cb = *b; + if (ca >= 'A' && ca <= 'Z') ca = (char)(ca - 'A' + 'a'); + if (cb >= 'A' && cb <= 'Z') cb = (char)(cb - 'A' + 'a'); + if (ca != cb) return 0; + a++; b++; + } + return *a == '\0' && *b == '\0'; +} + +/* 在 *line* 上拆分 "@",将指针存入 out_status/out_module + * (指向 *line* 内的位置,*line* 会被原地修改)。失败时返回 0。 */ +static int split_status_module(char *line, char **out_status, char **out_module) { + char *at = strchr(line, '@'); + if (!at) return 0; + *at = '\0'; + char *status = trim(line); + char *module = trim(at + 1); + if (*status == '\0' || *module == '\0') return 0; + *out_status = status; + *out_module = module; + return 1; +} + +/* 打印来自服务器的最近一次错误(如果有)。 */ +static void print_error(csm_client_t *c, csm_result_t r) { + csm_server_error_t err; + if (r == CSM_ERR_SERVER && csm_client_last_server_error(c, &err) == CSM_OK) { + if (err.code[0]) { + printf("Error: [%s] %s\n", err.code, err.message); + } else { + printf("Error: %s\n", err.message); + } + } else { + printf("Error: %s\n", csm_result_str(r)); + } +} + +/* 调用一个返回字符串的 SDK 辅助函数并打印结果。 */ +static void print_string_command(csm_client_t *c, csm_result_t r, char *text) { + if (r == CSM_OK) { + printf("%s\n", text ? text : ""); + csm_string_free(text); + } else { + print_error(c, r); + } +} + +/* 处理一行输入。返回 0 表示退出,否则返回 1。 */ +static int dispatch(csm_client_t *c, char *line) { + line = trim(line); + if (*line == '\0') return 1; + + /* 将命令字与参数拆分(参数保留空格)。 */ + char *cmd = line; + char *arg = strpbrk(line, " \t"); + if (arg) { + *arg++ = '\0'; + arg = trim(arg); + } else { + arg = (char *)""; + } + + if (ieq(cmd, "quit") || ieq(cmd, "exit")) { + return 0; + } + if (ieq(cmd, "help")) { + printf("%s\n", HELP_TEXT); + return 1; + } + if (ieq(cmd, "ping")) { + double ms = 0; + csm_result_t r = csm_client_ping(c, 2000, &ms); + if (r == CSM_OK) printf("Ping OK latency=%.1f ms\n", ms); + else printf("Ping failed.\n"); + return 1; + } + if (ieq(cmd, "list")) { + char *text = NULL; + print_string_command(c, csm_client_list_modules(c, &text, 5000), text); + return 1; + } + if (ieq(cmd, "api")) { + if (*arg == '\0') { printf("Error: usage: api \n"); return 1; } + char *text = NULL; + print_string_command(c, csm_client_list_api(c, arg, &text, 5000), text); + return 1; + } + if (ieq(cmd, "state")) { + if (*arg == '\0') { printf("Error: usage: state \n"); return 1; } + char *text = NULL; + print_string_command(c, csm_client_list_states(c, arg, &text, 5000), text); + return 1; + } + if (ieq(cmd, "mhelp")) { + if (*arg == '\0') { printf("Error: usage: mhelp \n"); return 1; } + char *text = NULL; + print_string_command(c, csm_client_help(c, arg, &text, 5000), text); + return 1; + } + if (ieq(cmd, "send")) { + if (*arg == '\0') { printf("Error: usage: send \n"); return 1; } + csm_command_response_t resp = {0}; + csm_result_t r = csm_client_send_and_wait(c, arg, 5000, &resp); + if (r == CSM_OK) { + printf("Response: %s\n", resp.raw ? (const char *)resp.raw : ""); + csm_command_response_dispose(&resp); + } else { + print_error(c, r); + } + return 1; + } + if (ieq(cmd, "post")) { + if (*arg == '\0') { printf("Error: usage: post \n"); return 1; } + csm_client_register_async_callback(c, arg, on_async, NULL); + csm_result_t r = csm_client_post(c, arg, 5000); + if (r == CSM_OK) printf("Async command sent.\n"); + else print_error(c, r); + return 1; + } + if (ieq(cmd, "nopost")) { + if (*arg == '\0') { printf("Error: usage: nopost \n"); return 1; } + csm_result_t r = csm_client_post_no_reply(c, arg, 5000); + if (r == CSM_OK) printf("No-reply command sent.\n"); + else print_error(c, r); + return 1; + } + if (ieq(cmd, "sub")) { + char *status = NULL, *module = NULL; + if (!split_status_module(arg, &status, &module)) { + printf("Error: expected '@'\n"); + return 1; + } + csm_result_t r = csm_client_subscribe_status(c, status, module, + on_status, NULL, 5000); + if (r == CSM_OK) printf("Subscribed to %s@%s\n", status, module); + else print_error(c, r); + return 1; + } + if (ieq(cmd, "unsub")) { + char *status = NULL, *module = NULL; + if (!split_status_module(arg, &status, &module)) { + printf("Error: expected '@'\n"); + return 1; + } + csm_result_t r = csm_client_unsubscribe_status(c, status, module, 5000); + if (r == CSM_OK) printf("Unsubscribed from %s@%s\n", status, module); + else print_error(c, r); + return 1; + } + + printf("Error: unknown command '%s'. Type 'help' for the command list.\n", + cmd); + return 1; +} + +int main(int argc, char **argv) { + const char *host = (argc > 1) ? argv[1] : DEFAULT_HOST; + int port = (argc > 2) ? atoi(argv[2]) : DEFAULT_PORT; + if (port <= 0 || port > 65535) { + fprintf(stderr, "Error: invalid port '%s'\n", argv[2]); + return 1; + } + + printf("CSM-TCP-Router Client Console\n"); + printf("Connecting to %s:%d ...\n", host, port); + + csm_client_t *c = csm_client_create(); + if (!c) { + fprintf(stderr, "Out of memory\n"); + return 1; + } + + csm_result_t r = csm_client_connect(c, host, (uint16_t)port, 5000); + if (r != CSM_OK) { + printf("Error: %s\n", csm_result_str(r)); + csm_client_destroy(c); + return 1; + } + + printf("Connected to %s:%d. Type 'help' for commands, 'quit' to exit.\n", + host, port); + + char line[LINE_BUFFER_SIZE]; + for (;;) { + printf("csm> "); + fflush(stdout); + if (!fgets(line, (int)sizeof(line), stdin)) { + printf("\n"); + break; + } + if (!dispatch(c, line)) break; + } + + csm_client_disconnect(c); + csm_client_destroy(c); + printf("Disconnected.\n"); + return 0; +} diff --git a/SDK/c/examples/subscribe_status.c b/SDK/c/examples/subscribe_status.c new file mode 100644 index 0000000..f4396bb --- /dev/null +++ b/SDK/c/examples/subscribe_status.c @@ -0,0 +1,65 @@ +/* subscribe_status.c - 演示使用回调进行实时状态订阅, + * 对应 examples/subscribe_status.py。 */ + +#if !defined(_WIN32) +# ifndef _POSIX_C_SOURCE +# define _POSIX_C_SOURCE 200809L +# endif +#endif + +#include "csm_tcp_router_client.h" + +#include +#include + +#if defined(_WIN32) +# include +static void sleep_ms(unsigned int ms){ Sleep(ms); } +#else +# include +static void sleep_ms(unsigned int ms){ + struct timespec ts; ts.tv_sec=ms/1000; ts.tv_nsec=(long)(ms%1000)*1000000L; + nanosleep(&ts, NULL); +} +#endif + +#define HOST "localhost" +#define PORT 30007 + +static void on_status(const csm_status_notification_t *n, void *ud) { + (void)ud; + printf("[%s @ %s] %s\n", n->status_name, n->module_name, n->data); +} + +int main(int argc, char **argv) { + const char *status_name = (argc > 1) ? argv[1] : "Status"; + const char *module_name = (argc > 2) ? argv[2] : "DAQmx"; + + csm_client_t *c = csm_client_create(); + if (!c) return 1; + + csm_result_t r = csm_client_connect(c, HOST, PORT, 5000); + if (r != CSM_OK) { + fprintf(stderr, "Connection failed: %s\n", csm_result_str(r)); + csm_client_destroy(c); + return 1; + } + + r = csm_client_subscribe_status(c, status_name, module_name, + on_status, NULL, 5000); + if (r != CSM_OK) { + fprintf(stderr, "Subscribe failed: %s\n", csm_result_str(r)); + csm_client_disconnect(c); + csm_client_destroy(c); + return 1; + } + + printf("Subscribed to %s@%s. Listening for 30s ...\n", + status_name, module_name); + sleep_ms(30000); + + csm_client_unsubscribe_status(c, status_name, module_name, 5000); + csm_client_disconnect(c); + csm_client_destroy(c); + return 0; +} diff --git a/SDK/c/include/csm_tcp_router_client.h b/SDK/c/include/csm_tcp_router_client.h new file mode 100644 index 0000000..378ad80 --- /dev/null +++ b/SDK/c/include/csm_tcp_router_client.h @@ -0,0 +1,399 @@ +/* csm_tcp_router_client.h - 适用于 CSM-TCP-Router 客户端 SDK 的单头文件公共 C API。 + * + * 本 SDK 是 Python `csm_tcp_router_client` 模块的 C 对应版本。 + * 它通过 TCP 实现 CSM-TCP-Router 协议 v0,并提供一个 + * 线程安全的同步客户端(`csm_client_t`),支持阻塞调用 + * 以及异步回调/轮询队列两种投递方式。 + * + * 线帧格式(8 字节头部,大端序): + * + * | Data Length (4B) | Version (1B=0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | Payload | + * +---------------------------- Header (8B) ----------------------------+ + * + * 快速入门: + * + * csm_client_t *c = csm_client_create(); + * if (csm_client_connect(c, "localhost", 30007, 5000) == CSM_OK) { + * char *modules = NULL; + * if (csm_client_list_modules(c, &modules, 5000) == CSM_OK) { + * printf("%s\n", modules); + * csm_string_free(modules); + * } + * csm_client_disconnect(c); + * } + * csm_client_destroy(c); + * + * 本库可跨平台运行于 Windows(Winsock2 + Win32 线程)和 + * POSIX 系统(BSD 套接字 + pthreads),通过附带的 CMake / Visual Studio 项目 + * 构建为静态库或共享库。 + */ +#ifndef CSM_TCP_ROUTER_CLIENT_H +#define CSM_TCP_ROUTER_CLIENT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------------- */ +/* 版本和 DLL 导出 */ +/* ------------------------------------------------------------------------- */ + +#define CSM_VERSION_MAJOR 0 +#define CSM_VERSION_MINOR 1 +#define CSM_VERSION_PATCH 0 +#define CSM_VERSION_STRING "0.1.0" + +#if defined(_WIN32) && defined(CSM_BUILD_SHARED) +# ifdef CSM_BUILD_LIBRARY +# define CSM_API __declspec(dllexport) +# else +# define CSM_API __declspec(dllimport) +# endif +#else +# define CSM_API +#endif + +/* ------------------------------------------------------------------------- */ +/* 返回码 */ +/* ------------------------------------------------------------------------- */ + +/** 所有公共 SDK 函数返回的结果码。 */ +typedef enum csm_result { + CSM_OK = 0, /**< 操作成功。 */ + CSM_ERR_INVALID = -1, /**< 无效参数或 NULL 指针。 */ + CSM_ERR_CONNECTION = -2, /**< 连接失败或已断开。 */ + CSM_ERR_TIMEOUT = -3, /**< 操作超时。 */ + CSM_ERR_PROTOCOL = -4, /**< 无效/格式错误的协议帧。 */ + CSM_ERR_SERVER = -5, /**< 服务器返回了 ERROR 数据包。 */ + CSM_ERR_NOMEM = -6, /**< 内存分配失败。 */ + CSM_ERR_STATE = -7, /**< 当前状态下操作无效。 */ + CSM_ERR_IO = -8 /**< 底层套接字/操作系统 I/O 错误。 */ +} csm_result_t; + +/** 返回 *code* 对应的静态可读字符串。 */ +CSM_API const char *csm_result_str(csm_result_t code); + +/* ------------------------------------------------------------------------- */ +/* 协议常量 */ +/* ------------------------------------------------------------------------- */ + +/** 数据包类型字节值(CSM-TCP-Router 协议 v0)。 */ +typedef enum csm_packet_type { + CSM_PT_INFO = 0x00, /**< 欢迎/再见信息文本。 */ + CSM_PT_ERROR = 0x01, /**< 服务器错误:"[Error: ] " */ + CSM_PT_CMD = 0x02, /**< 命令数据包(客户端 -> 服务器)。 */ + CSM_PT_CMD_RESP = 0x03, /**< 异步/订阅握手 ACK。 */ + CSM_PT_RESP = 0x04, /**< 同步响应载荷。 */ + CSM_PT_ASYNC_RESP = 0x05, /**< 异步响应:" <- "。 */ + CSM_PT_STATUS = 0x06, /**< 状态广播。 */ + CSM_PT_INTERRUPT = 0x07 /**< 中断广播。 */ +} csm_packet_type_t; + +/** 固定线帧格式头部的字节数。 */ +#define CSM_HEADER_SIZE 8 + +/** 每个发出数据包中发送的协议版本字节。 */ +#define CSM_PROTOCOL_VERSION 0x01 + +/* ------------------------------------------------------------------------- */ +/* 公共数据模型 */ +/* ------------------------------------------------------------------------- */ + +/** 已解码的数据包(头部字段 + 堆分配的主体)。 */ +typedef struct csm_packet { + csm_packet_type_t type; + uint8_t version; + uint8_t flag1; + uint8_t flag2; + uint8_t *data; /**< 拥有的载荷缓冲区(或 NULL)。 */ + size_t data_len; /**< `data` 的字节长度。 */ +} csm_packet_t; + +/** 成功的同步响应。 */ +typedef struct csm_command_response { + uint8_t *raw; /**< NUL 终止的 UTF-8 载荷(已拥有)。 */ + size_t raw_len; /**< `raw` 的字节长度(不含 NUL)。 */ +} csm_command_response_t; + +/** ASYNC_RESP 数据包:载荷 + 服务器回显的原始命令。 + * + * 服务器格式:``" <- "``。 */ +typedef struct csm_async_response { + char *raw; /**< 响应载荷(已拥有,NUL 终止)。 */ + size_t raw_len; + char *original_command; /**< 回显的命令文本(已拥有)。 */ +} csm_async_response_t; + +/** STATUS 或 INTERRUPT 广播。 + * + * 服务器格式:``" >> <- "``。 */ +typedef struct csm_status_notification { + csm_packet_type_t packet_type; /**< CSM_PT_STATUS 或 CSM_PT_INTERRUPT */ + char *raw; /**< 完整载荷(已拥有)。 */ + size_t raw_len; + char *status_name; /**< 已拥有,NUL 终止。 */ + char *data; /**< 已拥有,NUL 终止。 */ + char *module_name; /**< 已拥有,NUL 终止。 */ +} csm_status_notification_t; + +/** 释放通过出参(例如由 `csm_client_list_modules` 返回)分配的字符串。 + * 传入 NULL 时安全。 */ +CSM_API void csm_string_free(char *s); + +/** 释放 `csm_command_response_t` 的堆成员(不释放结构体本身)。 */ +CSM_API void csm_command_response_dispose(csm_command_response_t *resp); + +/** 释放 `csm_async_response_t` 的堆成员(不释放结构体本身)。 */ +CSM_API void csm_async_response_dispose(csm_async_response_t *resp); + +/** 释放 `csm_status_notification_t` 的堆成员(不释放结构体本身)。 */ +CSM_API void csm_status_notification_dispose(csm_status_notification_t *n); + +/** 释放 `csm_packet_t` 的堆成员(不释放结构体本身)。 */ +CSM_API void csm_packet_dispose(csm_packet_t *pkt); + +/* ------------------------------------------------------------------------- */ +/* 服务器错误信息 */ +/* ------------------------------------------------------------------------- */ + +/** 函数最近返回 CSM_ERR_SERVER 时的相关信息。 */ +typedef struct csm_server_error { + char code[32]; /**< NUL 终止的 CSM 错误码(可为空)。 */ + char message[256]; /**< NUL 终止的错误消息(已截断)。 */ +} csm_server_error_t; + +/* ------------------------------------------------------------------------- */ +/* 协议编解码(暴露用于高级用途/测试) */ +/* ------------------------------------------------------------------------- */ + +/** 将 *data*(`data_len` 字节)编码为完整的线帧格式数据包。 + * + * 调用者必须传入至少 `CSM_HEADER_SIZE + data_len` 字节的 `out_buf`。 + * 成功时 `*out_len` 将被设置为写入的字节数。 + */ +CSM_API csm_result_t csm_encode_packet(const void *data, + size_t data_len, + csm_packet_type_t type, + uint8_t flag1, + uint8_t flag2, + uint8_t *out_buf, + size_t out_buf_size, + size_t *out_len); + +/** 将 8 字节头部解码为各组成字段。 */ +CSM_API csm_result_t csm_decode_header(const uint8_t *header_bytes, + size_t header_len, + uint32_t *out_data_len, + uint8_t *out_version, + uint8_t *out_type, + uint8_t *out_flag1, + uint8_t *out_flag2); + +/** 从原始头部 + 主体构建 `csm_packet_t`。返回的数据包 + * **拥有** 主体的副本;使用 `csm_packet_dispose` 释放。 + * + * 未知数据包类型字节将被映射到 `CSM_PT_INFO` 以实现前向 + * 兼容性(服务器可能在未来版本中引入新类型)。 + */ +CSM_API csm_result_t csm_parse_packet(const uint8_t *header_bytes, + size_t header_len, + const uint8_t *body, + size_t body_len, + csm_packet_t *out_packet); + +/* ------------------------------------------------------------------------- */ +/* 回调签名 */ +/* ------------------------------------------------------------------------- */ + +/** 状态/中断通知回调。 + * + * 从接收线程调用。必须快速且非阻塞。`notif` + * 指针及其成员仅在调用期间有效; + * 请在返回前复制所需数据。 + */ +typedef void (*csm_status_callback_fn)(const csm_status_notification_t *notif, + void *user_data); + +/** 异步响应回调。与 `csm_status_callback_fn` 使用相同的线程规则。 */ +typedef void (*csm_async_callback_fn)(const csm_async_response_t *resp, + void *user_data); + +/* ------------------------------------------------------------------------- */ +/* 客户端生命周期 */ +/* ------------------------------------------------------------------------- */ + +/** 不透明的线程安全客户端句柄。 */ +typedef struct csm_client csm_client_t; + +/** 创建新的客户端实例。分配失败时返回 NULL。 */ +CSM_API csm_client_t *csm_client_create(void); + +/** 断开连接(如已连接)并释放 *client* 持有的所有资源。 */ +CSM_API void csm_client_destroy(csm_client_t *client); + +/** 打开 TCP 连接并启动后台接收线程。 + * + * @param connect_timeout_ms 连接超时时间(毫秒)。 + * @return CSM_OK 或 CSM_ERR_CONNECTION / CSM_ERR_STATE / CSM_ERR_INVALID。 + */ +CSM_API csm_result_t csm_client_connect(csm_client_t *client, + const char *host, + uint16_t port, + unsigned int connect_timeout_ms); + +/** 关闭连接并停止接收线程。未连接时调用安全; + * 任何被阻塞的调用者将立即收到 CSM_ERR_CONNECTION。 */ +CSM_API csm_result_t csm_client_disconnect(csm_client_t *client); + +/** 底层套接字打开时返回非零值。 */ +CSM_API int csm_client_is_connected(const csm_client_t *client); + +/** 轮询直到 *host*:*port* 接受连接或 *timeout_ms* 超时。 + * + * @return 服务器可达时返回 CSM_OK;否则返回 CSM_ERR_TIMEOUT。 */ +CSM_API csm_result_t csm_client_wait_for_server(const char *host, + uint16_t port, + unsigned int timeout_ms, + unsigned int retry_interval_ms); + +/* ------------------------------------------------------------------------- */ +/* 核心命令方法 */ +/* ------------------------------------------------------------------------- */ + +/** 发送同步命令并阻塞直到响应到达。 + * + * 返回 CSM_OK 时调用者拥有 `*out_resp`,必须通过 + * `csm_command_response_dispose` 释放。 */ +CSM_API csm_result_t csm_client_send_and_wait(csm_client_t *client, + const char *command, + unsigned int timeout_ms, + csm_command_response_t *out_resp); + +/** 发送异步命令(`->` 后缀)并阻塞直到 + * `cmd-resp` 握手到达。最终的 `async-resp` 将被投递到 + * 通过 `csm_client_register_async_callback` 注册的回调 + * 以及轮询队列(`csm_client_poll_async_response`)。 */ +CSM_API csm_result_t csm_client_post(csm_client_t *client, + const char *command, + unsigned int timeout_ms); + +/** 发送异步无回复命令(`->|` 后缀)并阻塞直到 + * `cmd-resp` 握手到达。 */ +CSM_API csm_result_t csm_client_post_no_reply(csm_client_t *client, + const char *command, + unsigned int timeout_ms); + +/** 发送 `Ping` 并测量往返延迟。 + * + * @param out_elapsed_ms 成功时设置为往返时间(毫秒)。 + * @return CSM_OK 或常规错误码之一。 + */ +CSM_API csm_result_t csm_client_ping(csm_client_t *client, + unsigned int timeout_ms, + double *out_elapsed_ms); + +/* ------------------------------------------------------------------------- */ +/* 路由器管理辅助函数 */ +/* ------------------------------------------------------------------------- */ + +/** 执行 `List` 并返回响应文本。调用者通过 + * `csm_string_free` 释放 `*out_text`。 */ +CSM_API csm_result_t csm_client_list_modules(csm_client_t *client, + char **out_text, + unsigned int timeout_ms); + +/** 执行 `List API `。调用者释放 `*out_text`。 */ +CSM_API csm_result_t csm_client_list_api(csm_client_t *client, + const char *module, + char **out_text, + unsigned int timeout_ms); + +/** 执行 `List State `。调用者释放 `*out_text`。 */ +CSM_API csm_result_t csm_client_list_states(csm_client_t *client, + const char *module, + char **out_text, + unsigned int timeout_ms); + +/** 执行 `Help `。调用者释放 `*out_text`。 */ +CSM_API csm_result_t csm_client_help(csm_client_t *client, + const char *module, + char **out_text, + unsigned int timeout_ms); + +/* ------------------------------------------------------------------------- */ +/* 状态/中断订阅 */ +/* ------------------------------------------------------------------------- */ + +/** 订阅 CSM 模块的状态广播。 + * + * 发送 ``"@ ->"`` 并阻塞直到 + * `cmd-resp` 握手到达。`callback`(若非 NULL)将从 + * 接收线程对每个通知调用;通知同时也会入队以供 + * 通过 `csm_client_poll_status` 轮询。 + */ +CSM_API csm_result_t csm_client_subscribe_status(csm_client_t *client, + const char *status_name, + const char *module_name, + csm_status_callback_fn callback, + void *user_data, + unsigned int timeout_ms); + +/** 取消状态订阅。 */ +CSM_API csm_result_t csm_client_unsubscribe_status(csm_client_t *client, + const char *status_name, + const char *module_name, + unsigned int timeout_ms); + +/** 为匹配 *original_command* 的 `async-resp` 数据包注册回调。 */ +CSM_API csm_result_t csm_client_register_async_callback(csm_client_t *client, + const char *original_command, + csm_async_callback_fn callback, + void *user_data); + +/** 移除之前注册的异步回调。 */ +CSM_API csm_result_t csm_client_unregister_async_callback(csm_client_t *client, + const char *original_command); + +/* ------------------------------------------------------------------------- */ +/* 轮询队列(回调的替代方案) */ +/* ------------------------------------------------------------------------- */ + +/** 从轮询队列弹出下一个状态/中断通知。 + * + * @param timeout_ms 0 = 非阻塞;>0 = 最多阻塞 N 毫秒。 + * @return 返回 CSM_OK 且 `*out_notif` 已填充(调用者通过 + * `csm_status_notification_dispose` 释放);若队列在 + * 超时内为空则返回 CSM_ERR_TIMEOUT;若断开连接则返回 CSM_ERR_CONNECTION。 + */ +CSM_API csm_result_t csm_client_poll_status(csm_client_t *client, + csm_status_notification_t *out_notif, + unsigned int timeout_ms); + +/** 从轮询队列弹出下一个异步响应。 */ +CSM_API csm_result_t csm_client_poll_async_response(csm_client_t *client, + csm_async_response_t *out_resp, + unsigned int timeout_ms); + +/* ------------------------------------------------------------------------- */ +/* 最后一次服务器错误 */ +/* ------------------------------------------------------------------------- */ + +/** 获取 *client* 观察到的最后一次 CSM_ERR_SERVER 的信息。 + * + * 返回 CSM_OK 并用最近捕获的服务器错误码/消息填充 *out_err*; + * 否则(该客户端从未观察到服务器错误)返回 CSM_ERR_STATE。 + * 存储的错误将无限期保留,直到下一次 CSM_ERR_SERVER 将其覆盖, + * 因此可以在操作失败后立即调用此函数, + * 而无需担心被中间无关的成功操作清除。 + */ +CSM_API csm_result_t csm_client_last_server_error(const csm_client_t *client, + csm_server_error_t *out_err); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* CSM_TCP_ROUTER_CLIENT_H */ diff --git a/SDK/c/src/csm_tcp_router_client.c b/SDK/c/src/csm_tcp_router_client.c new file mode 100644 index 0000000..7c3849a --- /dev/null +++ b/SDK/c/src/csm_tcp_router_client.c @@ -0,0 +1,1622 @@ +/* csm_tcp_router_client.c - CSM-TCP-Router C 客户端 SDK 的跨平台实现。 + * + * 线程模型:接收循环运行于单个后台线程。所有公共函数均可从任意线程安全调用; + * 客户端分别对同步(RESP)和命令握手(CMD_RESP)响应的并发等待者进行串行化, + * 与 Python SDK 保持一致。 + * + * 套接字 / 线程抽象: + * - Windows:Winsock2 + Win32 CRITICAL_SECTION / CONDITION_VARIABLE / 线程。 + * - POSIX: BSD 套接字 + pthreads。 + */ + +#if !defined(_WIN32) +# ifndef _POSIX_C_SOURCE +# define _POSIX_C_SOURCE 200809L +# endif +# ifndef _DEFAULT_SOURCE +# define _DEFAULT_SOURCE 1 +# endif +#endif + +#include "csm_tcp_router_client.h" + +#include +#include +#include +#include +#include +#include +#include + +/* CSM_BUILD_LIBRARY 由构建系统(CMake / MSBuild)在编译库时定义, + * 以便 csm_tcp_router_client.h 使用正确的 __declspec 装饰共享构建中 + * 导出的符号。在此处无条件定义会破坏那些将此 .c 文件直接编译进 + * 自己的 DLL(采用不同导出约定)的使用者。 */ + +/* ========================================================================= */ +/* 平台抽象 */ +/* ========================================================================= */ + +#if defined(_WIN32) +# define WIN32_LEAN_AND_MEAN +# include +# include +# include +# pragma comment(lib, "Ws2_32.lib") + +typedef SOCKET csm_socket_t; +# define CSM_INVALID_SOCKET INVALID_SOCKET +# define csm_close_socket(s) closesocket(s) +# define csm_socket_errno() WSAGetLastError() + +typedef CRITICAL_SECTION csm_mutex_t; +typedef CONDITION_VARIABLE csm_cond_t; +typedef HANDLE csm_thread_t; + +static void csm_mutex_init(csm_mutex_t *m) { InitializeCriticalSection(m); } +static void csm_mutex_destroy(csm_mutex_t *m) { DeleteCriticalSection(m); } +static void csm_mutex_lock(csm_mutex_t *m) { EnterCriticalSection(m); } +static void csm_mutex_unlock(csm_mutex_t *m) { LeaveCriticalSection(m); } + +static void csm_cond_init(csm_cond_t *c) { InitializeConditionVariable(c); } +static void csm_cond_destroy(csm_cond_t *c) { (void)c; } +static void csm_cond_signal(csm_cond_t *c) { WakeConditionVariable(c); } +#if 0 /* 保留供将来广播使用 */ +static void csm_cond_broadcast(csm_cond_t *c) { WakeAllConditionVariable(c); } +#endif + +/* 有信号时返回 1,超时返回 0。 */ +static int csm_cond_wait_ms(csm_cond_t *c, csm_mutex_t *m, unsigned int ms) { + BOOL ok = SleepConditionVariableCS(c, m, ms == 0 ? INFINITE : ms); + if (ok) return 1; + return 0; +} + +static void csm_sleep_ms(unsigned int ms) { Sleep(ms); } + +static double csm_monotonic_ms(void) { + LARGE_INTEGER freq, now; + QueryPerformanceFrequency(&freq); + QueryPerformanceCounter(&now); + return (double)now.QuadPart * 1000.0 / (double)freq.QuadPart; +} + +#else /* POSIX */ +# include +# include +# include +# include +# include +# include +# include +# include +# include +# include + +typedef int csm_socket_t; +# define CSM_INVALID_SOCKET (-1) +# define csm_close_socket(s) close(s) +# define csm_socket_errno() errno + +typedef pthread_mutex_t csm_mutex_t; +typedef pthread_cond_t csm_cond_t; +typedef pthread_t csm_thread_t; + +static void csm_mutex_init(csm_mutex_t *m) { pthread_mutex_init(m, NULL); } +static void csm_mutex_destroy(csm_mutex_t *m) { pthread_mutex_destroy(m); } +static void csm_mutex_lock(csm_mutex_t *m) { pthread_mutex_lock(m); } +static void csm_mutex_unlock(csm_mutex_t *m) { pthread_mutex_unlock(m); } + +static void csm_cond_init(csm_cond_t *c) { pthread_cond_init(c, NULL); } +static void csm_cond_destroy(csm_cond_t *c) { pthread_cond_destroy(c); } +static void csm_cond_signal(csm_cond_t *c) { pthread_cond_signal(c); } +#if 0 /* 保留供将来广播使用 */ +static void csm_cond_broadcast(csm_cond_t *c) { pthread_cond_broadcast(c); } +#endif + +static int csm_cond_wait_ms(csm_cond_t *c, csm_mutex_t *m, unsigned int ms) { + if (ms == 0) { + pthread_cond_wait(c, m); + return 1; + } + struct timespec ts; +# if defined(CLOCK_REALTIME) + clock_gettime(CLOCK_REALTIME, &ts); +# else + struct timeval tv; gettimeofday(&tv, NULL); + ts.tv_sec = tv.tv_sec; + ts.tv_nsec = tv.tv_usec * 1000; +# endif + ts.tv_sec += ms / 1000; + ts.tv_nsec += (long)(ms % 1000) * 1000000L; + if (ts.tv_nsec >= 1000000000L) { + ts.tv_sec += ts.tv_nsec / 1000000000L; + ts.tv_nsec = ts.tv_nsec % 1000000000L; + } + int rc = pthread_cond_timedwait(c, m, &ts); + return rc == 0 ? 1 : 0; +} + +static void csm_sleep_ms(unsigned int ms) { + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (long)(ms % 1000) * 1000000L; + nanosleep(&ts, NULL); +} + +static double csm_monotonic_ms(void) { + struct timespec ts; +# if defined(CLOCK_MONOTONIC) + clock_gettime(CLOCK_MONOTONIC, &ts); +# else + struct timeval tv; gettimeofday(&tv, NULL); + ts.tv_sec = tv.tv_sec; + ts.tv_nsec = tv.tv_usec * 1000; +# endif + return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1.0e6; +} +#endif + +/* ========================================================================= */ +/* WSA 引导(仅 Windows)— 引用计数 */ +/* ========================================================================= */ + +#if defined(_WIN32) +static csm_mutex_t g_wsa_lock; +static INIT_ONCE g_wsa_lock_init_once_state = INIT_ONCE_STATIC_INIT; +static int g_wsa_refcount = 0; + +static BOOL CALLBACK csm_wsa_lock_init_once_cb(PINIT_ONCE init_once, + PVOID param, + PVOID *context) { + (void)init_once; (void)param; (void)context; + csm_mutex_init(&g_wsa_lock); + return TRUE; +} + +static void csm_wsa_lock_init_once(void) { + /* InitOnceExecuteOnce 保证回调在进程内所有线程中恰好执行一次, + * 因此即使在并发创建客户端时,临界区也只会被初始化一次。 */ + InitOnceExecuteOnce(&g_wsa_lock_init_once_state, + csm_wsa_lock_init_once_cb, NULL, NULL); +} + +static int csm_wsa_startup(void) { + csm_wsa_lock_init_once(); + csm_mutex_lock(&g_wsa_lock); + if (g_wsa_refcount == 0) { + WSADATA d; + if (WSAStartup(MAKEWORD(2, 2), &d) != 0) { + csm_mutex_unlock(&g_wsa_lock); + return -1; + } + } + g_wsa_refcount++; + csm_mutex_unlock(&g_wsa_lock); + return 0; +} + +static void csm_wsa_cleanup(void) { + csm_wsa_lock_init_once(); + csm_mutex_lock(&g_wsa_lock); + if (g_wsa_refcount > 0) { + g_wsa_refcount--; + if (g_wsa_refcount == 0) WSACleanup(); + } + csm_mutex_unlock(&g_wsa_lock); +} +#else +static int csm_wsa_startup(void) { return 0; } +static void csm_wsa_cleanup(void) {} +#endif + +/* ========================================================================= */ +/* 结果码辅助函数 */ +/* ========================================================================= */ + +const char *csm_result_str(csm_result_t code) { + switch (code) { + case CSM_OK: return "OK"; + case CSM_ERR_INVALID: return "Invalid argument"; + case CSM_ERR_CONNECTION: return "Connection error"; + case CSM_ERR_TIMEOUT: return "Timeout"; + case CSM_ERR_PROTOCOL: return "Protocol error"; + case CSM_ERR_SERVER: return "Server error"; + case CSM_ERR_NOMEM: return "Out of memory"; + case CSM_ERR_STATE: return "Invalid state"; + case CSM_ERR_IO: return "I/O error"; + } + return "Unknown"; +} + +/* ========================================================================= */ +/* 内存辅助函数 */ +/* ========================================================================= */ + +static char *csm_strdup_n(const char *s, size_t n) { + char *out = (char *)malloc(n + 1); + if (!out) return NULL; + if (n) memcpy(out, s, n); + out[n] = '\0'; + return out; +} + +static char *csm_strdup_str(const char *s) { + return csm_strdup_n(s ? s : "", s ? strlen(s) : 0); +} + +void csm_string_free(char *s) { free(s); } + +void csm_command_response_dispose(csm_command_response_t *resp) { + if (!resp) return; + free(resp->raw); + resp->raw = NULL; + resp->raw_len = 0; +} + +void csm_async_response_dispose(csm_async_response_t *resp) { + if (!resp) return; + free(resp->raw); + free(resp->original_command); + resp->raw = NULL; + resp->original_command = NULL; + resp->raw_len = 0; +} + +void csm_status_notification_dispose(csm_status_notification_t *n) { + if (!n) return; + free(n->raw); + free(n->status_name); + free(n->data); + free(n->module_name); + n->raw = NULL; + n->status_name = NULL; + n->data = NULL; + n->module_name = NULL; + n->raw_len = 0; +} + +void csm_packet_dispose(csm_packet_t *pkt) { + if (!pkt) return; + free(pkt->data); + pkt->data = NULL; + pkt->data_len = 0; +} + +/* ========================================================================= */ +/* 协议编解码 */ +/* ========================================================================= */ + +static void csm_pack_be32(uint8_t *buf, uint32_t v) { + buf[0] = (uint8_t)((v >> 24) & 0xFF); + buf[1] = (uint8_t)((v >> 16) & 0xFF); + buf[2] = (uint8_t)((v >> 8) & 0xFF); + buf[3] = (uint8_t)( v & 0xFF); +} + +static uint32_t csm_unpack_be32(const uint8_t *buf) { + return ((uint32_t)buf[0] << 24) | + ((uint32_t)buf[1] << 16) | + ((uint32_t)buf[2] << 8) | + (uint32_t)buf[3]; +} + +csm_result_t csm_encode_packet(const void *data, + size_t data_len, + csm_packet_type_t type, + uint8_t flag1, + uint8_t flag2, + uint8_t *out_buf, + size_t out_buf_size, + size_t *out_len) { + if (!out_buf || (data_len > 0 && !data)) return CSM_ERR_INVALID; + if (out_buf_size < CSM_HEADER_SIZE + data_len) return CSM_ERR_INVALID; + + csm_pack_be32(out_buf, (uint32_t)data_len); + out_buf[4] = CSM_PROTOCOL_VERSION; + out_buf[5] = (uint8_t)type; + out_buf[6] = flag1; + out_buf[7] = flag2; + if (data_len) memcpy(out_buf + CSM_HEADER_SIZE, data, data_len); + if (out_len) *out_len = CSM_HEADER_SIZE + data_len; + return CSM_OK; +} + +csm_result_t csm_decode_header(const uint8_t *header_bytes, + size_t header_len, + uint32_t *out_data_len, + uint8_t *out_version, + uint8_t *out_type, + uint8_t *out_flag1, + uint8_t *out_flag2) { + if (!header_bytes || header_len != CSM_HEADER_SIZE) return CSM_ERR_PROTOCOL; + if (out_data_len) *out_data_len = csm_unpack_be32(header_bytes); + if (out_version) *out_version = header_bytes[4]; + if (out_type) *out_type = header_bytes[5]; + if (out_flag1) *out_flag1 = header_bytes[6]; + if (out_flag2) *out_flag2 = header_bytes[7]; + return CSM_OK; +} + +csm_result_t csm_parse_packet(const uint8_t *header_bytes, + size_t header_len, + const uint8_t *body, + size_t body_len, + csm_packet_t *out_packet) { + if (!out_packet) return CSM_ERR_INVALID; + uint32_t data_len = 0; + uint8_t version = 0, type_byte = 0, flag1 = 0, flag2 = 0; + csm_result_t r = csm_decode_header(header_bytes, header_len, &data_len, + &version, &type_byte, &flag1, &flag2); + if (r != CSM_OK) return r; + if ((size_t)data_len != body_len) return CSM_ERR_PROTOCOL; + + /* 向前兼容:未知类型字节映射为 INFO。 */ + csm_packet_type_t pt; + switch (type_byte) { + case CSM_PT_INFO: + case CSM_PT_ERROR: + case CSM_PT_CMD: + case CSM_PT_CMD_RESP: + case CSM_PT_RESP: + case CSM_PT_ASYNC_RESP: + case CSM_PT_STATUS: + case CSM_PT_INTERRUPT: + pt = (csm_packet_type_t)type_byte; + break; + default: + pt = CSM_PT_INFO; + break; + } + + out_packet->type = pt; + out_packet->version = version; + out_packet->flag1 = flag1; + out_packet->flag2 = flag2; + out_packet->data_len = body_len; + out_packet->data = NULL; + if (body_len > 0) { + out_packet->data = (uint8_t *)malloc(body_len); + if (!out_packet->data) return CSM_ERR_NOMEM; + memcpy(out_packet->data, body, body_len); + } + return CSM_OK; +} + +/* ========================================================================= */ +/* 内部:服务器错误解析 */ +/* ========================================================================= */ + +/* 将形如 "[Error: ] " 的数据包载荷解析到 out_err。 */ +static void csm_parse_server_error(const uint8_t *data, + size_t len, + csm_server_error_t *out_err) { + out_err->code[0] = '\0'; + out_err->message[0] = '\0'; + + /* 复制到以 NUL 结尾的栈缓冲区(截断至上限)。 */ + char buf[1024]; + size_t copy_len = len < sizeof(buf) - 1 ? len : sizeof(buf) - 1; + if (copy_len) memcpy(buf, data, copy_len); + buf[copy_len] = '\0'; + + /* 去除尾部空白字符。 */ + while (copy_len > 0 && (buf[copy_len - 1] == ' ' || + buf[copy_len - 1] == '\r' || + buf[copy_len - 1] == '\n' || + buf[copy_len - 1] == '\t')) { + buf[--copy_len] = '\0'; + } + + const char *prefix = "[Error:"; + size_t prefix_len = strlen(prefix); + const char *msg = buf; + if (copy_len >= prefix_len && strncmp(buf, prefix, prefix_len) == 0) { + char *end = strchr(buf, ']'); + if (end) { + size_t code_len = (size_t)(end - (buf + prefix_len)); + /* 去除错误码首尾的空格。 */ + const char *cs = buf + prefix_len; + while (code_len && *cs == ' ') { cs++; code_len--; } + while (code_len && cs[code_len - 1] == ' ') code_len--; + if (code_len >= sizeof(out_err->code)) + code_len = sizeof(out_err->code) - 1; + memcpy(out_err->code, cs, code_len); + out_err->code[code_len] = '\0'; + msg = end + 1; + while (*msg == ' ') msg++; + } + } + + size_t msg_len = strlen(msg); + if (msg_len >= sizeof(out_err->message)) + msg_len = sizeof(out_err->message) - 1; + memcpy(out_err->message, msg, msg_len); + out_err->message[msg_len] = '\0'; +} + +/* ========================================================================= */ +/* 内部:有界队列 */ +/* ========================================================================= */ + +/* 通用队列节点。元素持有数据包(用于 resp/cmd_resp)、 + * 通知 / 异步响应(用于轮询队列),或哨兵(通过 `is_disconnect` 发出信号)。 */ +typedef struct csm_queue_node { + struct csm_queue_node *next; + void *item; /* 类型取决于所属队列 */ + int is_disconnect; + int is_server_error; + csm_server_error_t server_error; +} csm_queue_node_t; + +typedef struct csm_queue { + csm_queue_node_t *head; + csm_queue_node_t *tail; + csm_mutex_t lock; + csm_cond_t cond; +} csm_queue_t; + +static void csm_queue_init(csm_queue_t *q) { + q->head = q->tail = NULL; + csm_mutex_init(&q->lock); + csm_cond_init(&q->cond); +} + +static void csm_queue_destroy_with(csm_queue_t *q, + void (*free_item)(void *)) { + csm_queue_node_t *n = q->head; + while (n) { + csm_queue_node_t *next = n->next; + if (n->item && free_item) free_item(n->item); + free(n); + n = next; + } + q->head = q->tail = NULL; + csm_cond_destroy(&q->cond); + csm_mutex_destroy(&q->lock); +} + +/* 压入一个元素;成功时获得 *item* 的所有权。 */ +static int csm_queue_push(csm_queue_t *q, void *item, + int is_disconnect, int is_server_error, + const csm_server_error_t *err) { + csm_queue_node_t *n = (csm_queue_node_t *)calloc(1, sizeof(*n)); + if (!n) return -1; + n->item = item; + n->is_disconnect = is_disconnect; + n->is_server_error = is_server_error; + if (err) n->server_error = *err; + + csm_mutex_lock(&q->lock); + if (q->tail) q->tail->next = n; + else q->head = n; + q->tail = n; + csm_cond_signal(&q->cond); + csm_mutex_unlock(&q->lock); + return 0; +} + +/* 弹出一个元素,最多阻塞 *timeout_ms* 毫秒。成功时返回 CSM_OK 并设置 *out_item* + * (所有权转移),超时返回 CSM_ERR_TIMEOUT,连接断开返回 CSM_ERR_CONNECTION + *(哨兵),或返回 CSM_ERR_SERVER(同时填充 *out_err*)。 */ +static csm_result_t csm_queue_pop(csm_queue_t *q, + unsigned int timeout_ms, + void **out_item, + csm_server_error_t *out_err) { + if (out_item) *out_item = NULL; + double deadline = csm_monotonic_ms() + (double)timeout_ms; + csm_mutex_lock(&q->lock); + while (q->head == NULL) { + double remaining = deadline - csm_monotonic_ms(); + if (remaining <= 0) { + csm_mutex_unlock(&q->lock); + return CSM_ERR_TIMEOUT; + } + unsigned int wait_ms = (unsigned int)remaining; + if (wait_ms == 0) wait_ms = 1; + csm_cond_wait_ms(&q->cond, &q->lock, wait_ms); + } + csm_queue_node_t *n = q->head; + q->head = n->next; + if (q->head == NULL) q->tail = NULL; + csm_mutex_unlock(&q->lock); + + csm_result_t result = CSM_OK; + if (n->is_disconnect) { + result = CSM_ERR_CONNECTION; + } else if (n->is_server_error) { + if (out_err) *out_err = n->server_error; + result = CSM_ERR_SERVER; + } else if (out_item) { + *out_item = n->item; + n->item = NULL; + } + if (n->item) { + /* 元素未被消费(例如调用者传入了 NULL out_item)。 + * 默认以字节方式通过调用者队列特定包装器设置的释放函数释放, + * 此处直接丢弃以保证无内存泄漏。 */ + free(n->item); + } + free(n); + return result; +} + +/* ========================================================================= */ +/* 订阅 / 异步回调注册表 */ +/* ========================================================================= */ + +typedef struct csm_status_sub { + struct csm_status_sub *next; + char *status_name; + char *module_name; + csm_status_callback_fn callback; + void *user_data; +} csm_status_sub_t; + +typedef struct csm_async_sub { + struct csm_async_sub *next; + char *original_command; + csm_async_callback_fn callback; + void *user_data; +} csm_async_sub_t; + +/* ========================================================================= */ +/* 客户端 */ +/* ========================================================================= */ + +struct csm_client { + csm_socket_t sock; + csm_thread_t recv_thread; + int recv_thread_running; + int connected; /* 在 state_lock 下设置 */ + int stop_flag; /* 置位以请求关闭 */ + + csm_mutex_t state_lock; /* 保护 connected/stop_flag/sock */ + csm_mutex_t send_lock; /* 串行化 sendall() */ + + csm_mutex_t resp_lock; /* 最多一个在途 RESP 等待者 */ + csm_mutex_t cmd_resp_lock; /* 最多一个在途 CMD_RESP 等待者 */ + + csm_queue_t resp_queue; /* 元素:csm_packet_t* */ + csm_queue_t cmd_resp_queue; /* 元素:csm_packet_t*(或 NULL) */ + csm_queue_t status_queue; /* 元素:csm_status_notification_t* */ + csm_queue_t async_queue; /* 元素:csm_async_response_t* */ + + csm_mutex_t sub_lock; /* 保护订阅注册表 */ + csm_status_sub_t *status_subs; + csm_async_sub_t *async_subs; + + csm_mutex_t err_lock; + int has_server_error; + csm_server_error_t last_server_error; +}; + +/* --- 辅助函数 --- */ + +static void csm_packet_free_void(void *p) { + csm_packet_t *pkt = (csm_packet_t *)p; + if (!pkt) return; + csm_packet_dispose(pkt); + free(pkt); +} + +static void csm_status_notif_free_void(void *p) { + csm_status_notification_t *n = (csm_status_notification_t *)p; + if (!n) return; + csm_status_notification_dispose(n); + free(n); +} + +static void csm_async_resp_free_void(void *p) { + csm_async_response_t *r = (csm_async_response_t *)p; + if (!r) return; + csm_async_response_dispose(r); + free(r); +} + +/* 在 state_lock 下设置 client.sock;关闭旧套接字(如有)。 */ +static void csm_set_socket_locked(csm_client_t *c, csm_socket_t s) { + if (c->sock != CSM_INVALID_SOCKET) csm_close_socket(c->sock); + c->sock = s; +} + +/* 记录最近一次服务器错误,以便调用者在收到 CSM_ERR_SERVER 后获取。 */ +static void csm_record_server_error(csm_client_t *c, + const csm_server_error_t *err) { + csm_mutex_lock(&c->err_lock); + c->has_server_error = 1; + c->last_server_error = *err; + csm_mutex_unlock(&c->err_lock); +} + +/* --- 订阅注册表(在 sub_lock 下操作)--- */ + +static csm_status_sub_t *csm_find_status_sub(csm_client_t *c, + const char *status_name, + const char *module_name) { + csm_status_sub_t *s = c->status_subs; + while (s) { + if (strcmp(s->status_name, status_name) == 0 && + strcmp(s->module_name, module_name) == 0) { + return s; + } + s = s->next; + } + return NULL; +} + +static csm_result_t csm_register_status_sub(csm_client_t *c, + const char *status_name, + const char *module_name, + csm_status_callback_fn callback, + void *user_data) { + csm_mutex_lock(&c->sub_lock); + csm_status_sub_t *existing = csm_find_status_sub(c, status_name, module_name); + if (existing) { + existing->callback = callback; + existing->user_data = user_data; + csm_mutex_unlock(&c->sub_lock); + return CSM_OK; + } + csm_status_sub_t *s = (csm_status_sub_t *)calloc(1, sizeof(*s)); + if (!s) { csm_mutex_unlock(&c->sub_lock); return CSM_ERR_NOMEM; } + s->status_name = csm_strdup_str(status_name); + s->module_name = csm_strdup_str(module_name); + if (!s->status_name || !s->module_name) { + free(s->status_name); free(s->module_name); free(s); + csm_mutex_unlock(&c->sub_lock); + return CSM_ERR_NOMEM; + } + s->callback = callback; + s->user_data = user_data; + s->next = c->status_subs; + c->status_subs = s; + csm_mutex_unlock(&c->sub_lock); + return CSM_OK; +} + +static void csm_remove_status_sub(csm_client_t *c, + const char *status_name, + const char *module_name) { + csm_mutex_lock(&c->sub_lock); + csm_status_sub_t **pp = &c->status_subs; + while (*pp) { + csm_status_sub_t *s = *pp; + if (strcmp(s->status_name, status_name) == 0 && + strcmp(s->module_name, module_name) == 0) { + *pp = s->next; + free(s->status_name); + free(s->module_name); + free(s); + break; + } + pp = &s->next; + } + csm_mutex_unlock(&c->sub_lock); +} + +static csm_result_t csm_register_async_sub(csm_client_t *c, + const char *original_command, + csm_async_callback_fn callback, + void *user_data) { + csm_mutex_lock(&c->sub_lock); + csm_async_sub_t *s = c->async_subs; + while (s) { + if (strcmp(s->original_command, original_command) == 0) { + s->callback = callback; + s->user_data = user_data; + csm_mutex_unlock(&c->sub_lock); + return CSM_OK; + } + s = s->next; + } + s = (csm_async_sub_t *)calloc(1, sizeof(*s)); + if (!s) { csm_mutex_unlock(&c->sub_lock); return CSM_ERR_NOMEM; } + s->original_command = csm_strdup_str(original_command); + if (!s->original_command) { free(s); csm_mutex_unlock(&c->sub_lock); return CSM_ERR_NOMEM; } + s->callback = callback; + s->user_data = user_data; + s->next = c->async_subs; + c->async_subs = s; + csm_mutex_unlock(&c->sub_lock); + return CSM_OK; +} + +static void csm_remove_async_sub(csm_client_t *c, const char *original_command) { + csm_mutex_lock(&c->sub_lock); + csm_async_sub_t **pp = &c->async_subs; + while (*pp) { + csm_async_sub_t *s = *pp; + if (strcmp(s->original_command, original_command) == 0) { + *pp = s->next; + free(s->original_command); + free(s); + break; + } + pp = &s->next; + } + csm_mutex_unlock(&c->sub_lock); +} + +static void csm_free_all_subs(csm_client_t *c) { + csm_mutex_lock(&c->sub_lock); + csm_status_sub_t *s = c->status_subs; + while (s) { csm_status_sub_t *n = s->next; free(s->status_name); free(s->module_name); free(s); s = n; } + c->status_subs = NULL; + csm_async_sub_t *a = c->async_subs; + while (a) { csm_async_sub_t *n = a->next; free(a->original_command); free(a); a = n; } + c->async_subs = NULL; + csm_mutex_unlock(&c->sub_lock); +} + +/* --- 接收辅助函数 --- */ + +/* 从套接字精确读取 *size* 字节;成功返回 0,EOF/错误返回 -1。 */ +static int csm_recv_all(csm_socket_t sock, uint8_t *buf, size_t size) { + size_t total = 0; + while (total < size) { +#if defined(_WIN32) + int n = recv(sock, (char *)buf + total, (int)(size - total), 0); +#else + ssize_t n = recv(sock, buf + total, size - total, 0); +#endif + if (n <= 0) return -1; + total += (size_t)n; + } + return 0; +} + +/* --- ASYNC_RESP / STATUS 载荷的解析辅助函数 --- */ + +static void csm_async_resp_free_void(void *p); +static void csm_status_notif_free_void(void *p); + +/* 从原始载荷数据构造 csm_async_response_t。 */ +static csm_async_response_t *csm_make_async_response(const uint8_t *data, + size_t len) { + csm_async_response_t *r = (csm_async_response_t *)calloc(1, sizeof(*r)); + if (!r) return NULL; + /* 服务器格式:" <- "。 */ + const char *sep = " <- "; + const size_t seplen = 4; + size_t split = (size_t)-1; + if (len >= seplen) { + for (size_t i = 0; i + seplen <= len; ++i) { + if (memcmp(data + i, sep, seplen) == 0) { split = i; break; } + } + } + if (split != (size_t)-1) { + r->raw = csm_strdup_n((const char *)data, split); + r->raw_len = split; + r->original_command = csm_strdup_n((const char *)data + split + seplen, + len - split - seplen); + } else { + r->raw = csm_strdup_n((const char *)data, len); + r->raw_len = len; + r->original_command = csm_strdup_str(""); + } + if (!r->raw || !r->original_command) { + csm_async_resp_free_void(r); + return NULL; + } + return r; +} + +/* 从原始载荷数据构造 csm_status_notification_t。 */ +static csm_status_notification_t *csm_make_status_notif(csm_packet_type_t pt, + const uint8_t *data, + size_t len) { + csm_status_notification_t *n = (csm_status_notification_t *)calloc(1, sizeof(*n)); + if (!n) return NULL; + n->packet_type = pt; + n->raw = (char *)malloc(len + 1); + if (!n->raw) { free(n); return NULL; } + if (len) memcpy(n->raw, data, len); + n->raw[len] = '\0'; + n->raw_len = len; + + /* 从右向左查找最后一个 " <- " 分隔符(rsplit by 1)。 */ + const char *raw_str = n->raw; + const char *left = raw_str; + size_t left_len = len; + const char *module_start = NULL; + size_t module_len = 0; + if (len >= 4) { + for (size_t i = len - 4 + 1; i-- > 0; ) { + if (memcmp(raw_str + i, " <- ", 4) == 0) { + left_len = i; + module_start = raw_str + i + 4; + module_len = len - i - 4; + break; + } + } + } + + /* 去除模块名首尾空白字符。 */ + while (module_len && (*module_start == ' ' || *module_start == '\t')) { + module_start++; module_len--; + } + while (module_len && (module_start[module_len - 1] == ' ' || + module_start[module_len - 1] == '\t' || + module_start[module_len - 1] == '\r' || + module_start[module_len - 1] == '\n')) { + module_len--; + } + + /* 以 " >> " 将左半部分拆分为 status_name 和 data。 */ + const char *status_start = NULL; + size_t status_len = 0; + const char *data_start = left; + size_t data_len_local = left_len; + if (left_len >= 4) { + for (size_t i = 0; i + 4 <= left_len; ++i) { + if (memcmp(left + i, " >> ", 4) == 0) { + status_start = left; + status_len = i; + data_start = left + i + 4; + data_len_local = left_len - i - 4; + break; + } + } + } + + /* 去除 status_name 和 data 首尾空白字符。 */ + while (status_len && (*status_start == ' ' || *status_start == '\t')) { status_start++; status_len--; } + while (status_len && (status_start[status_len - 1] == ' ' || status_start[status_len - 1] == '\t')) status_len--; + while (data_len_local && (*data_start == ' ' || *data_start == '\t')) { data_start++; data_len_local--; } + while (data_len_local && (data_start[data_len_local - 1] == ' ' || + data_start[data_len_local - 1] == '\t' || + data_start[data_len_local - 1] == '\r' || + data_start[data_len_local - 1] == '\n')) data_len_local--; + + n->status_name = csm_strdup_n(status_start ? status_start : "", status_len); + n->data = csm_strdup_n(data_start, data_len_local); + n->module_name = csm_strdup_n(module_start ? module_start : "", module_len); + if (!n->status_name || !n->data || !n->module_name) { + csm_status_notif_free_void(n); + return NULL; + } + return n; +} + +/* --- 接收线程 --- */ + +static void csm_dispatch_packet(csm_client_t *c, csm_packet_t *pkt) { + /* 收到 RESP / CMD_RESP / ERROR 时,将数据包(或错误哨兵)的所有权转入队列。 + * 收到 STATUS / ASYNC_RESP / INTERRUPT 时,构造高层对象并释放原始数据包。 */ + switch (pkt->type) { + case CSM_PT_RESP: { + csm_packet_t *heap = (csm_packet_t *)malloc(sizeof(*heap)); + if (!heap) { csm_packet_free_void(pkt); return; } + *heap = *pkt; + /* 压入 resp 队列;队列节点持有所有权。 */ + if (csm_queue_push(&c->resp_queue, heap, 0, 0, NULL) != 0) { + csm_packet_free_void(heap); + } + free(pkt); + return; + } + case CSM_PT_CMD_RESP: { + csm_packet_t *heap = (csm_packet_t *)malloc(sizeof(*heap)); + if (!heap) { csm_packet_free_void(pkt); return; } + *heap = *pkt; + if (csm_queue_push(&c->cmd_resp_queue, heap, 0, 0, NULL) != 0) { + csm_packet_free_void(heap); + } + free(pkt); + return; + } + case CSM_PT_ERROR: { + csm_server_error_t err; + csm_parse_server_error(pkt->data, pkt->data_len, &err); + csm_record_server_error(c, &err); + csm_queue_push(&c->resp_queue, NULL, 0, 1, &err); + csm_queue_push(&c->cmd_resp_queue, NULL, 0, 1, &err); + csm_packet_free_void(pkt); + return; + } + case CSM_PT_ASYNC_RESP: { + csm_async_response_t *r = csm_make_async_response(pkt->data, pkt->data_len); + if (r) { + /* 在 sub_lock 下查找回调。 */ + csm_mutex_lock(&c->sub_lock); + csm_async_callback_fn cb = NULL; void *ud = NULL; + csm_async_sub_t *s = c->async_subs; + while (s) { + if (strcmp(s->original_command, r->original_command) == 0) { + cb = s->callback; ud = s->user_data; break; + } + s = s->next; + } + csm_mutex_unlock(&c->sub_lock); + if (cb) cb(r, ud); + /* 将副本压入轮询队列,使回调用户与轮询用户相互独立。 */ + csm_async_response_t *queued = (csm_async_response_t *)calloc(1, sizeof(*queued)); + if (queued) { + queued->raw = csm_strdup_n(r->raw, r->raw_len); + queued->raw_len = r->raw_len; + queued->original_command = csm_strdup_str(r->original_command); + if (queued->raw && queued->original_command) { + if (csm_queue_push(&c->async_queue, queued, 0, 0, NULL) != 0) + csm_async_resp_free_void(queued); + } else { + csm_async_resp_free_void(queued); + } + } + csm_async_resp_free_void(r); + } + csm_packet_free_void(pkt); + return; + } + case CSM_PT_STATUS: + case CSM_PT_INTERRUPT: { + csm_status_notification_t *n = csm_make_status_notif(pkt->type, pkt->data, pkt->data_len); + if (n) { + csm_mutex_lock(&c->sub_lock); + csm_status_callback_fn cb = NULL; void *ud = NULL; + csm_status_sub_t *s = c->status_subs; + while (s) { + if (strcmp(s->status_name, n->status_name) == 0 && + strcmp(s->module_name, n->module_name) == 0) { + cb = s->callback; ud = s->user_data; break; + } + s = s->next; + } + csm_mutex_unlock(&c->sub_lock); + if (cb) cb(n, ud); + /* 将副本压入轮询队列。 */ + csm_status_notification_t *q = (csm_status_notification_t *)calloc(1, sizeof(*q)); + if (q) { + q->packet_type = n->packet_type; + q->raw = csm_strdup_n(n->raw, n->raw_len); + q->raw_len = n->raw_len; + q->status_name = csm_strdup_str(n->status_name); + q->data = csm_strdup_str(n->data); + q->module_name = csm_strdup_str(n->module_name); + if (q->raw && q->status_name && q->data && q->module_name) { + if (csm_queue_push(&c->status_queue, q, 0, 0, NULL) != 0) + csm_status_notif_free_void(q); + } else { + csm_status_notif_free_void(q); + } + } + csm_status_notif_free_void(n); + } + csm_packet_free_void(pkt); + return; + } + case CSM_PT_INFO: + case CSM_PT_CMD: + default: + /* INFO 静默丢弃;CMD 不由服务器发送。 */ + csm_packet_free_void(pkt); + return; + } +} + +#if defined(_WIN32) +static unsigned __stdcall csm_recv_thread_main(void *arg) +#else +static void *csm_recv_thread_main(void *arg) +#endif +{ + csm_client_t *c = (csm_client_t *)arg; + uint8_t header[CSM_HEADER_SIZE]; + for (;;) { + /* 在 state_lock 下快照 stop_flag 和 sock。csm_client_disconnect() + * 在同一锁下修改这两个字段,因此不会出现撕裂读或过时的 sock 值, + * TSAN/UBSan 也不会产生警告。 */ + csm_mutex_lock(&c->state_lock); + int stop_flag = c->stop_flag; + csm_socket_t sock = c->sock; + csm_mutex_unlock(&c->state_lock); + + if (stop_flag) break; + if (sock == CSM_INVALID_SOCKET) break; + if (csm_recv_all(sock, header, CSM_HEADER_SIZE) != 0) break; + uint32_t data_len = csm_unpack_be32(header); + uint8_t *body = NULL; + if (data_len > 0) { + body = (uint8_t *)malloc(data_len); + if (!body) break; + if (csm_recv_all(sock, body, data_len) != 0) { + free(body); + break; + } + } + csm_packet_t parsed = {0}; + csm_result_t r = csm_parse_packet(header, CSM_HEADER_SIZE, body, data_len, &parsed); + free(body); + if (r != CSM_OK) { + /* 跳过损坏帧;保持循环运行。 */ + continue; + } + /* 分配堆副本以将所有权传递给派发函数。 */ + csm_packet_t *heap_pkt = (csm_packet_t *)malloc(sizeof(*heap_pkt)); + if (!heap_pkt) { + csm_packet_dispose(&parsed); + continue; + } + *heap_pkt = parsed; + csm_dispatch_packet(c, heap_pkt); + } + + /* 通知所有阻塞的等待者连接已断开。 */ + csm_queue_push(&c->resp_queue, NULL, 1, 0, NULL); + csm_queue_push(&c->cmd_resp_queue, NULL, 1, 0, NULL); + csm_queue_push(&c->status_queue, NULL, 1, 0, NULL); + csm_queue_push(&c->async_queue, NULL, 1, 0, NULL); + + csm_mutex_lock(&c->state_lock); + c->connected = 0; + csm_mutex_unlock(&c->state_lock); +#if defined(_WIN32) + return 0; +#else + return NULL; +#endif +} + +/* --- 生命周期 --- */ + +csm_client_t *csm_client_create(void) { + if (csm_wsa_startup() != 0) return NULL; + csm_client_t *c = (csm_client_t *)calloc(1, sizeof(*c)); + if (!c) { csm_wsa_cleanup(); return NULL; } + c->sock = CSM_INVALID_SOCKET; + csm_mutex_init(&c->state_lock); + csm_mutex_init(&c->send_lock); + csm_mutex_init(&c->resp_lock); + csm_mutex_init(&c->cmd_resp_lock); + csm_mutex_init(&c->sub_lock); + csm_mutex_init(&c->err_lock); + csm_queue_init(&c->resp_queue); + csm_queue_init(&c->cmd_resp_queue); + csm_queue_init(&c->status_queue); + csm_queue_init(&c->async_queue); + return c; +} + +void csm_client_destroy(csm_client_t *client) { + if (!client) return; + csm_client_disconnect(client); + csm_queue_destroy_with(&client->resp_queue, csm_packet_free_void); + csm_queue_destroy_with(&client->cmd_resp_queue, csm_packet_free_void); + csm_queue_destroy_with(&client->status_queue, csm_status_notif_free_void); + csm_queue_destroy_with(&client->async_queue, csm_async_resp_free_void); + csm_free_all_subs(client); + csm_mutex_destroy(&client->state_lock); + csm_mutex_destroy(&client->send_lock); + csm_mutex_destroy(&client->resp_lock); + csm_mutex_destroy(&client->cmd_resp_lock); + csm_mutex_destroy(&client->sub_lock); + csm_mutex_destroy(&client->err_lock); + free(client); + csm_wsa_cleanup(); +} + +/* 解析主机名并在超时限制内建立连接。返回 CSM_OK 或 + * CSM_ERR_CONNECTION / CSM_ERR_TIMEOUT。 */ +static csm_result_t csm_do_connect(const char *host, uint16_t port, + unsigned int timeout_ms, + csm_socket_t *out_sock) { + char port_str[16]; + snprintf(port_str, sizeof(port_str), "%u", (unsigned)port); + + struct addrinfo hints, *res = NULL; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + if (getaddrinfo(host, port_str, &hints, &res) != 0 || !res) { + return CSM_ERR_CONNECTION; + } + + csm_result_t result = CSM_ERR_CONNECTION; + csm_socket_t sock = CSM_INVALID_SOCKET; + for (struct addrinfo *ai = res; ai; ai = ai->ai_next) { + sock = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); + if (sock == CSM_INVALID_SOCKET) continue; + + /* 切换为非阻塞模式以实现带超时的 connect。 */ +#if defined(_WIN32) + u_long mode = 1; + ioctlsocket(sock, FIONBIO, &mode); +#else + int flags = fcntl(sock, F_GETFL, 0); + if (flags == -1 || fcntl(sock, F_SETFL, flags | O_NONBLOCK) == -1) { + csm_close_socket(sock); + sock = CSM_INVALID_SOCKET; + continue; + } +#endif + + int cr = connect(sock, ai->ai_addr, (int)ai->ai_addrlen); + if (cr == 0) { + result = CSM_OK; + } else { +#if defined(_WIN32) + int err = WSAGetLastError(); + int in_progress = (err == WSAEWOULDBLOCK); +#else + int in_progress = (errno == EINPROGRESS); +#endif + if (in_progress) { + fd_set wfds; + FD_ZERO(&wfds); + FD_SET(sock, &wfds); + struct timeval tv; + tv.tv_sec = timeout_ms / 1000; + tv.tv_usec = (long)(timeout_ms % 1000) * 1000; + int sel = select((int)(sock + 1), NULL, &wfds, NULL, + timeout_ms > 0 ? &tv : NULL); + if (sel > 0) { + int so_err = 0; + socklen_t sl = sizeof(so_err); + if (getsockopt(sock, SOL_SOCKET, SO_ERROR, + (char *)&so_err, &sl) == 0 && so_err == 0) { + result = CSM_OK; + } + } else if (sel == 0) { + result = CSM_ERR_TIMEOUT; + } + } + } + + if (result == CSM_OK) { + /* 切换回阻塞模式供接收循环使用。 */ +#if defined(_WIN32) + u_long mode2 = 0; + ioctlsocket(sock, FIONBIO, &mode2); +#else + int flags2 = fcntl(sock, F_GETFL, 0); + fcntl(sock, F_SETFL, flags2 & ~O_NONBLOCK); +#endif + *out_sock = sock; + break; + } + csm_close_socket(sock); + sock = CSM_INVALID_SOCKET; + } + + freeaddrinfo(res); + return result; +} + +csm_result_t csm_client_connect(csm_client_t *client, + const char *host, + uint16_t port, + unsigned int connect_timeout_ms) { + if (!client || !host) return CSM_ERR_INVALID; + + csm_mutex_lock(&client->state_lock); + if (client->connected) { + csm_mutex_unlock(&client->state_lock); + return CSM_ERR_STATE; + } + csm_mutex_unlock(&client->state_lock); + + csm_socket_t sock = CSM_INVALID_SOCKET; + csm_result_t r = csm_do_connect(host, port, + connect_timeout_ms ? connect_timeout_ms : 5000, + &sock); + if (r != CSM_OK) return r; + + csm_mutex_lock(&client->state_lock); + csm_set_socket_locked(client, sock); + client->connected = 1; + client->stop_flag = 0; + csm_mutex_unlock(&client->state_lock); + + csm_mutex_lock(&client->err_lock); + client->has_server_error = 0; + csm_mutex_unlock(&client->err_lock); + +#if defined(_WIN32) + client->recv_thread = (HANDLE)_beginthreadex(NULL, 0, csm_recv_thread_main, + client, 0, NULL); + if (client->recv_thread == NULL) { + csm_mutex_lock(&client->state_lock); + csm_set_socket_locked(client, CSM_INVALID_SOCKET); + client->connected = 0; + csm_mutex_unlock(&client->state_lock); + return CSM_ERR_IO; + } +#else + if (pthread_create(&client->recv_thread, NULL, csm_recv_thread_main, client) != 0) { + csm_mutex_lock(&client->state_lock); + csm_set_socket_locked(client, CSM_INVALID_SOCKET); + client->connected = 0; + csm_mutex_unlock(&client->state_lock); + return CSM_ERR_IO; + } +#endif + client->recv_thread_running = 1; + return CSM_OK; +} + +csm_result_t csm_client_disconnect(csm_client_t *client) { + if (!client) return CSM_ERR_INVALID; + csm_mutex_lock(&client->state_lock); + int was_connected = client->connected; + client->stop_flag = 1; + client->connected = 0; + csm_socket_t s = client->sock; + client->sock = CSM_INVALID_SOCKET; + csm_mutex_unlock(&client->state_lock); + + /* 在关闭套接字前唤醒所有阻塞的等待者。 */ + csm_queue_push(&client->resp_queue, NULL, 1, 0, NULL); + csm_queue_push(&client->cmd_resp_queue, NULL, 1, 0, NULL); + csm_queue_push(&client->status_queue, NULL, 1, 0, NULL); + csm_queue_push(&client->async_queue, NULL, 1, 0, NULL); + + if (s != CSM_INVALID_SOCKET) { +#if defined(_WIN32) + shutdown(s, SD_BOTH); +#else + shutdown(s, SHUT_RDWR); +#endif + csm_close_socket(s); + } + + if (client->recv_thread_running) { +#if defined(_WIN32) + WaitForSingleObject(client->recv_thread, 2000); + CloseHandle(client->recv_thread); +#else + pthread_join(client->recv_thread, NULL); +#endif + client->recv_thread_running = 0; + } + return was_connected ? CSM_OK : CSM_OK; +} + +int csm_client_is_connected(const csm_client_t *client) { + if (!client) return 0; + /* 去除 const 以获取锁;逻辑上这是一次读操作。 */ + csm_client_t *mc = (csm_client_t *)client; + csm_mutex_lock(&mc->state_lock); + int v = mc->connected; + csm_mutex_unlock(&mc->state_lock); + return v; +} + +csm_result_t csm_client_wait_for_server(const char *host, + uint16_t port, + unsigned int timeout_ms, + unsigned int retry_interval_ms) { + if (!host) return CSM_ERR_INVALID; + if (csm_wsa_startup() != 0) return CSM_ERR_IO; + double deadline = csm_monotonic_ms() + (double)timeout_ms; + csm_result_t result = CSM_ERR_TIMEOUT; + while (csm_monotonic_ms() < deadline) { + csm_socket_t s = CSM_INVALID_SOCKET; + csm_result_t r = csm_do_connect(host, port, 1000, &s); + if (r == CSM_OK) { + csm_close_socket(s); + result = CSM_OK; + break; + } + csm_sleep_ms(retry_interval_ms ? retry_interval_ms : 500); + } + csm_wsa_cleanup(); + return result; +} + +/* --- 发送辅助函数 --- */ + +static csm_result_t csm_send_raw(csm_client_t *client, + const uint8_t *data, size_t len) { + csm_mutex_lock(&client->state_lock); + if (!client->connected) { + csm_mutex_unlock(&client->state_lock); + return CSM_ERR_CONNECTION; + } + csm_socket_t sock = client->sock; + csm_mutex_unlock(&client->state_lock); + + csm_mutex_lock(&client->send_lock); + size_t total = 0; +#if defined(MSG_NOSIGNAL) + int send_flags = MSG_NOSIGNAL; +#else + int send_flags = 0; +#endif + while (total < len) { +#if defined(_WIN32) + int n = send(sock, (const char *)data + total, (int)(len - total), 0); + (void)send_flags; +#else + ssize_t n = send(sock, data + total, len - total, send_flags); +#endif + if (n <= 0) { + csm_mutex_unlock(&client->send_lock); + csm_mutex_lock(&client->state_lock); + client->stop_flag = 1; + csm_mutex_unlock(&client->state_lock); + return CSM_ERR_CONNECTION; + } + total += (size_t)n; + } + csm_mutex_unlock(&client->send_lock); + return CSM_OK; +} + +/* 打包并发送 CMD 数据包。 */ +static csm_result_t csm_send_cmd(csm_client_t *client, const char *command) { + size_t len = strlen(command); + uint8_t *buf = (uint8_t *)malloc(CSM_HEADER_SIZE + len); + if (!buf) return CSM_ERR_NOMEM; + size_t out_len = 0; + csm_result_t r = csm_encode_packet(command, len, CSM_PT_CMD, 0, 0, + buf, CSM_HEADER_SIZE + len, &out_len); + if (r == CSM_OK) r = csm_send_raw(client, buf, out_len); + free(buf); + return r; +} + +/* --- 等待辅助函数 --- */ + +static csm_result_t csm_wait_for_resp(csm_client_t *client, + unsigned int timeout_ms, + csm_command_response_t *out_resp) { + void *item = NULL; + csm_server_error_t err = {0}; + csm_result_t r = csm_queue_pop(&client->resp_queue, timeout_ms, &item, &err); + if (r == CSM_ERR_SERVER) { + csm_record_server_error(client, &err); + return CSM_ERR_SERVER; + } + if (r != CSM_OK) return r; + csm_packet_t *pkt = (csm_packet_t *)item; + if (out_resp) { + out_resp->raw_len = pkt->data_len; + out_resp->raw = (uint8_t *)malloc(pkt->data_len + 1); + if (!out_resp->raw) { csm_packet_free_void(pkt); return CSM_ERR_NOMEM; } + if (pkt->data_len) memcpy(out_resp->raw, pkt->data, pkt->data_len); + out_resp->raw[pkt->data_len] = 0; + } + csm_packet_free_void(pkt); + return CSM_OK; +} + +static csm_result_t csm_wait_for_cmd_resp(csm_client_t *client, + unsigned int timeout_ms) { + void *item = NULL; + csm_server_error_t err = {0}; + csm_result_t r = csm_queue_pop(&client->cmd_resp_queue, timeout_ms, &item, &err); + if (r == CSM_ERR_SERVER) { + csm_record_server_error(client, &err); + return CSM_ERR_SERVER; + } + if (r != CSM_OK) return r; + /* 丢弃握手载荷。 */ + csm_packet_t *pkt = (csm_packet_t *)item; + csm_packet_free_void(pkt); + return CSM_OK; +} + +/* --- 公共命令 API --- */ + +csm_result_t csm_client_send_and_wait(csm_client_t *client, + const char *command, + unsigned int timeout_ms, + csm_command_response_t *out_resp) { + if (!client || !command) return CSM_ERR_INVALID; + if (out_resp) { out_resp->raw = NULL; out_resp->raw_len = 0; } + + csm_mutex_lock(&client->resp_lock); + csm_result_t r = csm_send_cmd(client, command); + if (r == CSM_OK) r = csm_wait_for_resp(client, timeout_ms ? timeout_ms : 5000, out_resp); + csm_mutex_unlock(&client->resp_lock); + return r; +} + +csm_result_t csm_client_post(csm_client_t *client, const char *command, + unsigned int timeout_ms) { + if (!client || !command) return CSM_ERR_INVALID; + csm_mutex_lock(&client->cmd_resp_lock); + csm_result_t r = csm_send_cmd(client, command); + if (r == CSM_OK) r = csm_wait_for_cmd_resp(client, timeout_ms ? timeout_ms : 5000); + csm_mutex_unlock(&client->cmd_resp_lock); + return r; +} + +csm_result_t csm_client_post_no_reply(csm_client_t *client, const char *command, + unsigned int timeout_ms) { + return csm_client_post(client, command, timeout_ms); +} + +csm_result_t csm_client_ping(csm_client_t *client, unsigned int timeout_ms, + double *out_elapsed_ms) { + if (out_elapsed_ms) *out_elapsed_ms = 0.0; + csm_command_response_t resp = {0}; + double t0 = csm_monotonic_ms(); + csm_result_t r = csm_client_send_and_wait(client, "Ping", + timeout_ms ? timeout_ms : 2000, &resp); + csm_command_response_dispose(&resp); + if (r != CSM_OK) return r; + if (out_elapsed_ms) *out_elapsed_ms = csm_monotonic_ms() - t0; + return CSM_OK; +} + +/* 共享辅助函数:发送固定文本命令并返回响应文本。 */ +static csm_result_t csm_send_text_query(csm_client_t *client, + const char *command, + char **out_text, + unsigned int timeout_ms) { + if (!out_text) return CSM_ERR_INVALID; + *out_text = NULL; + csm_command_response_t resp = {0}; + csm_result_t r = csm_client_send_and_wait(client, command, timeout_ms, &resp); + if (r == CSM_OK) { + *out_text = (char *)resp.raw; /* 转移所有权;已以 NUL 结尾 */ + resp.raw = NULL; + } else { + csm_command_response_dispose(&resp); + } + return r; +} + +csm_result_t csm_client_list_modules(csm_client_t *client, char **out_text, + unsigned int timeout_ms) { + return csm_send_text_query(client, "List", out_text, timeout_ms); +} + +/* 构造 " " 命令并发送。 */ +static csm_result_t csm_send_text_query_2(csm_client_t *client, + const char *prefix, + const char *module, + char **out_text, + unsigned int timeout_ms) { + if (!module) return CSM_ERR_INVALID; + size_t plen = strlen(prefix); + size_t mlen = strlen(module); + char *cmd = (char *)malloc(plen + 1 + mlen + 1); + if (!cmd) return CSM_ERR_NOMEM; + memcpy(cmd, prefix, plen); + cmd[plen] = ' '; + memcpy(cmd + plen + 1, module, mlen); + cmd[plen + 1 + mlen] = '\0'; + csm_result_t r = csm_send_text_query(client, cmd, out_text, timeout_ms); + free(cmd); + return r; +} + +csm_result_t csm_client_list_api(csm_client_t *client, const char *module, + char **out_text, unsigned int timeout_ms) { + return csm_send_text_query_2(client, "List API", module, out_text, timeout_ms); +} + +csm_result_t csm_client_list_states(csm_client_t *client, const char *module, + char **out_text, unsigned int timeout_ms) { + return csm_send_text_query_2(client, "List State", module, out_text, timeout_ms); +} + +csm_result_t csm_client_help(csm_client_t *client, const char *module, + char **out_text, unsigned int timeout_ms) { + return csm_send_text_query_2(client, "Help", module, out_text, timeout_ms); +} + +/* --- 订阅 --- */ + +csm_result_t csm_client_subscribe_status(csm_client_t *client, + const char *status_name, + const char *module_name, + csm_status_callback_fn callback, + void *user_data, + unsigned int timeout_ms) { + if (!client || !status_name || !module_name) return CSM_ERR_INVALID; + + /* 先注册,以消除 STATUS 在回调存储前到达的竞态条件。 */ + csm_result_t r = csm_register_status_sub(client, status_name, module_name, + callback, user_data); + if (r != CSM_OK) return r; + + /* 构造 "@ ->"。 */ + size_t s_len = strlen(status_name); + size_t m_len = strlen(module_name); + const char *suffix = " ->"; + size_t suf_len = strlen(suffix); + char *cmd = (char *)malloc(s_len + 1 + m_len + suf_len + 1); + if (!cmd) { csm_remove_status_sub(client, status_name, module_name); return CSM_ERR_NOMEM; } + memcpy(cmd, status_name, s_len); + cmd[s_len] = '@'; + memcpy(cmd + s_len + 1, module_name, m_len); + memcpy(cmd + s_len + 1 + m_len, suffix, suf_len); + cmd[s_len + 1 + m_len + suf_len] = '\0'; + + csm_mutex_lock(&client->cmd_resp_lock); + r = csm_send_cmd(client, cmd); + if (r == CSM_OK) r = csm_wait_for_cmd_resp(client, timeout_ms ? timeout_ms : 5000); + csm_mutex_unlock(&client->cmd_resp_lock); + free(cmd); + + if (r != CSM_OK) csm_remove_status_sub(client, status_name, module_name); + return r; +} + +csm_result_t csm_client_unsubscribe_status(csm_client_t *client, + const char *status_name, + const char *module_name, + unsigned int timeout_ms) { + if (!client || !status_name || !module_name) return CSM_ERR_INVALID; + size_t s_len = strlen(status_name); + size_t m_len = strlen(module_name); + const char *suffix = " ->"; + size_t suf_len = strlen(suffix); + char *cmd = (char *)malloc(s_len + 1 + m_len + suf_len + 1); + if (!cmd) return CSM_ERR_NOMEM; + memcpy(cmd, status_name, s_len); + cmd[s_len] = '@'; + memcpy(cmd + s_len + 1, module_name, m_len); + memcpy(cmd + s_len + 1 + m_len, suffix, suf_len); + cmd[s_len + 1 + m_len + suf_len] = '\0'; + + csm_mutex_lock(&client->cmd_resp_lock); + csm_result_t r = csm_send_cmd(client, cmd); + if (r == CSM_OK) r = csm_wait_for_cmd_resp(client, timeout_ms ? timeout_ms : 5000); + csm_mutex_unlock(&client->cmd_resp_lock); + free(cmd); + + csm_remove_status_sub(client, status_name, module_name); + return r; +} + +csm_result_t csm_client_register_async_callback(csm_client_t *client, + const char *original_command, + csm_async_callback_fn callback, + void *user_data) { + if (!client || !original_command || !callback) return CSM_ERR_INVALID; + return csm_register_async_sub(client, original_command, callback, user_data); +} + +csm_result_t csm_client_unregister_async_callback(csm_client_t *client, + const char *original_command) { + if (!client || !original_command) return CSM_ERR_INVALID; + csm_remove_async_sub(client, original_command); + return CSM_OK; +} + +/* --- 轮询队列 --- */ + +csm_result_t csm_client_poll_status(csm_client_t *client, + csm_status_notification_t *out_notif, + unsigned int timeout_ms) { + if (!client || !out_notif) return CSM_ERR_INVALID; + memset(out_notif, 0, sizeof(*out_notif)); + void *item = NULL; + csm_result_t r = csm_queue_pop(&client->status_queue, timeout_ms, &item, NULL); + if (r != CSM_OK) return r; + csm_status_notification_t *src = (csm_status_notification_t *)item; + /* 将字段所有权从 src 移至 out_notif。 */ + *out_notif = *src; + free(src); + return CSM_OK; +} + +csm_result_t csm_client_poll_async_response(csm_client_t *client, + csm_async_response_t *out_resp, + unsigned int timeout_ms) { + if (!client || !out_resp) return CSM_ERR_INVALID; + memset(out_resp, 0, sizeof(*out_resp)); + void *item = NULL; + csm_result_t r = csm_queue_pop(&client->async_queue, timeout_ms, &item, NULL); + if (r != CSM_OK) return r; + csm_async_response_t *src = (csm_async_response_t *)item; + *out_resp = *src; + free(src); + return CSM_OK; +} + +csm_result_t csm_client_last_server_error(const csm_client_t *client, + csm_server_error_t *out_err) { + if (!client || !out_err) return CSM_ERR_INVALID; + csm_client_t *mc = (csm_client_t *)client; + csm_mutex_lock(&mc->err_lock); + csm_result_t r = mc->has_server_error ? CSM_OK : CSM_ERR_STATE; + if (r == CSM_OK) *out_err = mc->last_server_error; + csm_mutex_unlock(&mc->err_lock); + return r; +} diff --git a/SDK/c/tests/mock_server.c b/SDK/c/tests/mock_server.c new file mode 100644 index 0000000..7c62f4b --- /dev/null +++ b/SDK/c/tests/mock_server.c @@ -0,0 +1,562 @@ +/* mock_server.c - 测试模拟服务器的跨平台实现。 */ + +#if !defined(_WIN32) +# ifndef _POSIX_C_SOURCE +# define _POSIX_C_SOURCE 200809L +# endif +# ifndef _DEFAULT_SOURCE +# define _DEFAULT_SOURCE 1 +# endif +#endif + +#include "mock_server.h" + +#include +#include +#include + +#if defined(_WIN32) +# define WIN32_LEAN_AND_MEAN +# include +# include +# include +# include +# pragma comment(lib, "Ws2_32.lib") +typedef SOCKET ms_socket_t; +# define MS_INVALID_SOCKET INVALID_SOCKET +# define ms_close_socket(s) closesocket(s) +typedef CRITICAL_SECTION ms_mutex_t; +typedef CONDITION_VARIABLE ms_cond_t; +typedef HANDLE ms_thread_t; +static void ms_mutex_init(ms_mutex_t *m){InitializeCriticalSection(m);} +static void ms_mutex_destroy(ms_mutex_t *m){DeleteCriticalSection(m);} +static void ms_mutex_lock(ms_mutex_t *m){EnterCriticalSection(m);} +static void ms_mutex_unlock(ms_mutex_t *m){LeaveCriticalSection(m);} +static void ms_cond_init(ms_cond_t *c){InitializeConditionVariable(c);} +static void ms_cond_destroy(ms_cond_t *c){(void)c;} +static void ms_cond_signal(ms_cond_t *c){WakeConditionVariable(c);} +static int ms_cond_wait_ms(ms_cond_t *c, ms_mutex_t *m, unsigned int ms){ + return SleepConditionVariableCS(c, m, ms == 0 ? INFINITE : ms) ? 1 : 0; +} +#if 0 /* 保留供将来使用 */ +static void ms_sleep_ms(unsigned int ms){Sleep(ms);} +#endif +#else +# include +# include +# include +# include +# include +# include +# include +# include +typedef int ms_socket_t; +# define MS_INVALID_SOCKET (-1) +# define ms_close_socket(s) close(s) +typedef pthread_mutex_t ms_mutex_t; +typedef pthread_cond_t ms_cond_t; +typedef pthread_t ms_thread_t; +static void ms_mutex_init(ms_mutex_t *m){pthread_mutex_init(m,NULL);} +static void ms_mutex_destroy(ms_mutex_t *m){pthread_mutex_destroy(m);} +static void ms_mutex_lock(ms_mutex_t *m){pthread_mutex_lock(m);} +static void ms_mutex_unlock(ms_mutex_t *m){pthread_mutex_unlock(m);} +static void ms_cond_init(ms_cond_t *c){pthread_cond_init(c,NULL);} +static void ms_cond_destroy(ms_cond_t *c){pthread_cond_destroy(c);} +static void ms_cond_signal(ms_cond_t *c){pthread_cond_signal(c);} +static int ms_cond_wait_ms(ms_cond_t *c, ms_mutex_t *m, unsigned int ms){ + if (ms == 0){pthread_cond_wait(c,m);return 1;} + struct timespec ts; clock_gettime(CLOCK_REALTIME,&ts); + ts.tv_sec += ms/1000; + ts.tv_nsec += (long)(ms%1000)*1000000L; + if (ts.tv_nsec>=1000000000L){ts.tv_sec+=ts.tv_nsec/1000000000L;ts.tv_nsec%=1000000000L;} + return pthread_cond_timedwait(c,m,&ts) == 0 ? 1 : 0; +} +#if 0 /* 保留供将来使用 */ +static void ms_sleep_ms(unsigned int ms){ + struct timespec ts; ts.tv_sec=ms/1000; ts.tv_nsec=(long)(ms%1000)*1000000L; + nanosleep(&ts,NULL); +} +#endif +#endif + +#ifndef INADDR_LOOPBACK +# define INADDR_LOOPBACK 0x7F000001UL +#endif + +#define MS_MAX_CLIENTS 16 +#define MS_HEADER 8 +#define MS_VER 0x01 + +/* --- 响应映射 --- */ +typedef struct ms_resp { + struct ms_resp *next; + char *cmd; + csm_packet_type_t type; + uint8_t *data; + size_t data_len; +} ms_resp_t; + +/* --- 已接收命令队列 --- */ +typedef struct ms_msg { + struct ms_msg *next; + char *text; +} ms_msg_t; + +struct csm_mock_server { + ms_socket_t listen_sock; + uint16_t port; + int stop; + int thread_started; + ms_thread_t accept_thread; + + ms_mutex_t resp_lock; + ms_resp_t *responses; + + ms_mutex_t recv_lock; + ms_cond_t recv_cond; + ms_msg_t *msg_head; + ms_msg_t *msg_tail; + + ms_mutex_t client_lock; + ms_socket_t clients[MS_MAX_CLIENTS]; + + ms_mutex_t handler_lock; + int handler_count; + ms_cond_t handler_done; +}; + +static int ms_wsa_init(void) { +#if defined(_WIN32) + WSADATA d; return WSAStartup(MAKEWORD(2,2), &d) == 0 ? 0 : -1; +#else + return 0; +#endif +} +static void ms_wsa_cleanup(void) { +#if defined(_WIN32) + WSACleanup(); +#endif +} + +static void ms_pack_be32(uint8_t *b, uint32_t v) { + b[0]=(uint8_t)((v>>24)&0xFF); b[1]=(uint8_t)((v>>16)&0xFF); + b[2]=(uint8_t)((v>>8)&0xFF); b[3]=(uint8_t)(v&0xFF); +} +static uint32_t ms_unpack_be32(const uint8_t *b){ + return ((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|(uint32_t)b[3]; +} + +/* 将头部 + 载荷编码到新分配的缓冲区中;调用者负责释放。 */ +static uint8_t *ms_encode(csm_packet_type_t type, const void *data, size_t len, size_t *out_len) { + uint8_t *buf = (uint8_t *)malloc(MS_HEADER + len); + if (!buf) return NULL; + ms_pack_be32(buf, (uint32_t)len); + buf[4] = MS_VER; buf[5] = (uint8_t)type; buf[6] = 0; buf[7] = 0; + if (len) memcpy(buf + MS_HEADER, data, len); + *out_len = MS_HEADER + len; + return buf; +} + +static int ms_send_all(ms_socket_t s, const uint8_t *buf, size_t len) { + size_t total = 0; +#if defined(MSG_NOSIGNAL) + int flags = MSG_NOSIGNAL; +#else + int flags = 0; +#endif + while (total < len) { +#if defined(_WIN32) + int n = send(s, (const char *)buf + total, (int)(len - total), 0); + (void)flags; +#else + ssize_t n = send(s, buf + total, len - total, flags); +#endif + if (n <= 0) return -1; + total += (size_t)n; + } + return 0; +} + +static int ms_recv_all(ms_socket_t s, uint8_t *buf, size_t len) { + size_t total = 0; + while (total < len) { +#if defined(_WIN32) + int n = recv(s, (char *)buf + total, (int)(len - total), 0); +#else + ssize_t n = recv(s, buf + total, len - total, 0); +#endif + if (n <= 0) return -1; + total += (size_t)n; + } + return 0; +} + +/* --- 公共 API --- */ + +csm_mock_server_t *csm_mock_server_create(void) { + if (ms_wsa_init() != 0) return NULL; + csm_mock_server_t *s = (csm_mock_server_t *)calloc(1, sizeof(*s)); + if (!s) { ms_wsa_cleanup(); return NULL; } + s->listen_sock = MS_INVALID_SOCKET; + ms_mutex_init(&s->resp_lock); + ms_mutex_init(&s->recv_lock); + ms_cond_init(&s->recv_cond); + ms_mutex_init(&s->client_lock); + ms_mutex_init(&s->handler_lock); + ms_cond_init(&s->handler_done); + for (int i = 0; i < MS_MAX_CLIENTS; ++i) s->clients[i] = MS_INVALID_SOCKET; + return s; +} + +static void ms_handle_command(csm_mock_server_t *s, ms_socket_t conn, + const char *cmd) { + /* 查找自定义响应。 */ + ms_mutex_lock(&s->resp_lock); + ms_resp_t *r = s->responses; + while (r) { + if (strcmp(r->cmd, cmd) == 0) { + size_t out_len = 0; + uint8_t *wire = ms_encode(r->type, r->data, r->data_len, &out_len); + ms_mutex_unlock(&s->resp_lock); + if (wire) { ms_send_all(conn, wire, out_len); free(wire); } + return; + } + r = r->next; + } + ms_mutex_unlock(&s->resp_lock); + + /* 内置默认值。 */ + size_t out_len = 0; + uint8_t *wire = NULL; + if (strcmp(cmd, "Ping") == 0) { + wire = ms_encode(CSM_PT_RESP, "Pong", 4, &out_len); + } else if (strcmp(cmd, "List") == 0) { + const char *txt = "AI\nDIO\nSystem"; + wire = ms_encode(CSM_PT_RESP, txt, strlen(txt), &out_len); + } else if (strncmp(cmd, "List API ", 9) == 0) { + char buf[256]; + snprintf(buf, sizeof(buf), "API: Start -> %s\nAPI: Stop -> %s", + cmd + 9, cmd + 9); + wire = ms_encode(CSM_PT_RESP, buf, strlen(buf), &out_len); + } else if (strncmp(cmd, "List State ", 11) == 0) { + char buf[256]; + snprintf(buf, sizeof(buf), "Idle <- %s\nRunning <- %s", + cmd + 11, cmd + 11); + wire = ms_encode(CSM_PT_RESP, buf, strlen(buf), &out_len); + } else if (strstr(cmd, "->") || strstr(cmd, "->")) { + wire = ms_encode(CSM_PT_CMD_RESP, "", 0, &out_len); + } else { + /* 通用异步握手。 */ + wire = ms_encode(CSM_PT_CMD_RESP, "", 0, &out_len); + } + if (wire) { ms_send_all(conn, wire, out_len); free(wire); } +} + +#if defined(_WIN32) +static unsigned __stdcall +#else +static void * +#endif +ms_client_thread(void *arg) { + typedef struct { csm_mock_server_t *s; ms_socket_t conn; } ms_ctx_t; + ms_ctx_t *ctx = (ms_ctx_t *)arg; + csm_mock_server_t *s = ctx->s; + ms_socket_t conn = ctx->conn; + free(ctx); + + /* 发送欢迎 INFO 包。 */ + size_t wlen = 0; + uint8_t *welcome = ms_encode(CSM_PT_INFO, "Welcome to mock server", 22, &wlen); + if (welcome) { ms_send_all(conn, welcome, wlen); free(welcome); } + + while (!s->stop) { + uint8_t hdr[MS_HEADER]; + if (ms_recv_all(conn, hdr, MS_HEADER) != 0) break; + uint32_t data_len = ms_unpack_be32(hdr); + uint8_t *body = NULL; + if (data_len) { + body = (uint8_t *)malloc(data_len); + if (!body) break; + if (ms_recv_all(conn, body, data_len) != 0) { free(body); break; } + } + if (hdr[5] == CSM_PT_CMD) { + char *cmd = (char *)malloc(data_len + 1); + if (cmd) { + if (data_len) memcpy(cmd, body, data_len); + cmd[data_len] = '\0'; + + /* 修剪尾部空白。 */ + size_t L = strlen(cmd); + while (L && (cmd[L-1]==' '||cmd[L-1]=='\r'||cmd[L-1]=='\n'||cmd[L-1]=='\t')) + cmd[--L] = '\0'; + + /* 先处理命令(使用本地副本)。 */ + ms_handle_command(s, conn, cmd); + + /* 然后将副本入队供测试检查。 */ + ms_msg_t *m = (ms_msg_t *)calloc(1, sizeof(*m)); + if (m) { + m->text = (char *)malloc(strlen(cmd) + 1); + if (m->text) { + strcpy(m->text, cmd); + ms_mutex_lock(&s->recv_lock); + if (s->msg_tail) s->msg_tail->next = m; + else s->msg_head = m; + s->msg_tail = m; + ms_cond_signal(&s->recv_cond); + ms_mutex_unlock(&s->recv_lock); + } else { + free(m); + } + } + free(cmd); + } + } + free(body); + } + + /* 从客户端列表中移除。 */ + ms_mutex_lock(&s->client_lock); + for (int i = 0; i < MS_MAX_CLIENTS; ++i) { + if (s->clients[i] == conn) { s->clients[i] = MS_INVALID_SOCKET; break; } + } + ms_mutex_unlock(&s->client_lock); + ms_close_socket(conn); + + ms_mutex_lock(&s->handler_lock); + s->handler_count--; + if (s->handler_count == 0) ms_cond_signal(&s->handler_done); + ms_mutex_unlock(&s->handler_lock); +#if defined(_WIN32) + return 0; +#else + return NULL; +#endif +} + +#if defined(_WIN32) +static unsigned __stdcall +#else +static void * +#endif +ms_accept_thread(void *arg) { + csm_mock_server_t *s = (csm_mock_server_t *)arg; + while (!s->stop) { + fd_set rfds; FD_ZERO(&rfds); FD_SET(s->listen_sock, &rfds); + struct timeval tv; tv.tv_sec = 0; tv.tv_usec = 200 * 1000; + int sel = select((int)(s->listen_sock + 1), &rfds, NULL, NULL, &tv); + if (sel <= 0) continue; + struct sockaddr_in addr; socklen_t alen = sizeof(addr); + ms_socket_t conn = accept(s->listen_sock, (struct sockaddr *)&addr, &alen); + if (conn == MS_INVALID_SOCKET) continue; + ms_mutex_lock(&s->client_lock); + for (int i = 0; i < MS_MAX_CLIENTS; ++i) { + if (s->clients[i] == MS_INVALID_SOCKET) { s->clients[i] = conn; break; } + } + ms_mutex_unlock(&s->client_lock); + + typedef struct { csm_mock_server_t *s; ms_socket_t conn; } ms_ctx_t; + ms_ctx_t *ctx = (ms_ctx_t *)malloc(sizeof(*ctx)); + if (!ctx) { ms_close_socket(conn); continue; } + ctx->s = s; ctx->conn = conn; + + ms_mutex_lock(&s->handler_lock); + s->handler_count++; + ms_mutex_unlock(&s->handler_lock); + +#if defined(_WIN32) + HANDLE t = (HANDLE)_beginthreadex(NULL, 0, ms_client_thread, ctx, 0, NULL); + if (t) CloseHandle(t); + else { + free(ctx); ms_close_socket(conn); + ms_mutex_lock(&s->handler_lock); s->handler_count--; ms_mutex_unlock(&s->handler_lock); + } +#else + pthread_t t; + if (pthread_create(&t, NULL, ms_client_thread, ctx) == 0) { + pthread_detach(t); + } else { + free(ctx); ms_close_socket(conn); + ms_mutex_lock(&s->handler_lock); s->handler_count--; ms_mutex_unlock(&s->handler_lock); + } +#endif + } +#if defined(_WIN32) + return 0; +#else + return NULL; +#endif +} + +int csm_mock_server_start(csm_mock_server_t *s) { + if (!s) return -1; + s->listen_sock = socket(AF_INET, SOCK_STREAM, 0); + if (s->listen_sock == MS_INVALID_SOCKET) return -1; + int yes = 1; + setsockopt(s->listen_sock, SOL_SOCKET, SO_REUSEADDR, (const char *)&yes, sizeof(yes)); + struct sockaddr_in a; memset(&a, 0, sizeof(a)); + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + a.sin_port = 0; + if (bind(s->listen_sock, (struct sockaddr *)&a, sizeof(a)) != 0) { + ms_close_socket(s->listen_sock); s->listen_sock = MS_INVALID_SOCKET; return -1; + } + socklen_t alen = sizeof(a); + getsockname(s->listen_sock, (struct sockaddr *)&a, &alen); + s->port = ntohs(a.sin_port); + if (listen(s->listen_sock, 8) != 0) { + ms_close_socket(s->listen_sock); s->listen_sock = MS_INVALID_SOCKET; return -1; + } + s->stop = 0; +#if defined(_WIN32) + s->accept_thread = (HANDLE)_beginthreadex(NULL, 0, ms_accept_thread, s, 0, NULL); + if (s->accept_thread == NULL) return -1; +#else + if (pthread_create(&s->accept_thread, NULL, ms_accept_thread, s) != 0) return -1; +#endif + s->thread_started = 1; + return 0; +} + +void csm_mock_server_stop(csm_mock_server_t *s) { + if (!s) return; + s->stop = 1; + if (s->listen_sock != MS_INVALID_SOCKET) { +#if defined(_WIN32) + shutdown(s->listen_sock, SD_BOTH); +#else + shutdown(s->listen_sock, SHUT_RDWR); +#endif + ms_close_socket(s->listen_sock); + s->listen_sock = MS_INVALID_SOCKET; + } + /* 关闭客户端套接字以唤醒处理程序。 */ + ms_mutex_lock(&s->client_lock); + for (int i = 0; i < MS_MAX_CLIENTS; ++i) { + if (s->clients[i] != MS_INVALID_SOCKET) { +#if defined(_WIN32) + shutdown(s->clients[i], SD_BOTH); +#else + shutdown(s->clients[i], SHUT_RDWR); +#endif + ms_close_socket(s->clients[i]); + s->clients[i] = MS_INVALID_SOCKET; + } + } + ms_mutex_unlock(&s->client_lock); + + if (s->thread_started) { +#if defined(_WIN32) + WaitForSingleObject(s->accept_thread, 2000); + CloseHandle(s->accept_thread); +#else + pthread_join(s->accept_thread, NULL); +#endif + s->thread_started = 0; + } + /* 等待处理程序完成(尽力而为,时间较短)。 */ + ms_mutex_lock(&s->handler_lock); + int waited = 0; + while (s->handler_count > 0 && waited < 20) { + ms_cond_wait_ms(&s->handler_done, &s->handler_lock, 100); + waited++; + } + ms_mutex_unlock(&s->handler_lock); +} + +void csm_mock_server_destroy(csm_mock_server_t *s) { + if (!s) return; + csm_mock_server_stop(s); + ms_mutex_lock(&s->resp_lock); + ms_resp_t *r = s->responses; + while (r) { ms_resp_t *n = r->next; free(r->cmd); free(r->data); free(r); r = n; } + s->responses = NULL; + ms_mutex_unlock(&s->resp_lock); + + ms_mutex_lock(&s->recv_lock); + ms_msg_t *m = s->msg_head; + while (m) { ms_msg_t *n = m->next; free(m->text); free(m); m = n; } + s->msg_head = s->msg_tail = NULL; + ms_mutex_unlock(&s->recv_lock); + + ms_mutex_destroy(&s->resp_lock); + ms_mutex_destroy(&s->recv_lock); + ms_cond_destroy(&s->recv_cond); + ms_mutex_destroy(&s->client_lock); + ms_mutex_destroy(&s->handler_lock); + ms_cond_destroy(&s->handler_done); + free(s); + ms_wsa_cleanup(); +} + +uint16_t csm_mock_server_port(const csm_mock_server_t *s) { + return s ? s->port : 0; +} + +static void ms_set_response_typed(csm_mock_server_t *s, const char *cmd_text, + csm_packet_type_t type, const char *data) { + ms_resp_t *r = (ms_resp_t *)calloc(1, sizeof(*r)); + if (!r) return; + r->cmd = (char *)malloc(strlen(cmd_text) + 1); + if (!r->cmd) { + free(r); + return; + } + strcpy(r->cmd, cmd_text); + r->type = type; + size_t dl = strlen(data); + if (dl > 0) { + r->data = (uint8_t *)malloc(dl); + if (!r->data) { + free(r->cmd); + free(r); + return; + } + memcpy(r->data, data, dl); + } + r->data_len = dl; + ms_mutex_lock(&s->resp_lock); + r->next = s->responses; + s->responses = r; + ms_mutex_unlock(&s->resp_lock); +} + +void csm_mock_server_set_response(csm_mock_server_t *s, const char *cmd, const char *resp) { + ms_set_response_typed(s, cmd, CSM_PT_RESP, resp); +} +void csm_mock_server_set_error_response(csm_mock_server_t *s, const char *cmd, const char *err) { + ms_set_response_typed(s, cmd, CSM_PT_ERROR, err); +} + +void csm_mock_server_push_status(csm_mock_server_t *s, const char *payload) { + if (!s || !payload) return; + size_t len = 0; + uint8_t *wire = ms_encode(CSM_PT_STATUS, payload, strlen(payload), &len); + if (!wire) return; + ms_mutex_lock(&s->client_lock); + for (int i = 0; i < MS_MAX_CLIENTS; ++i) { + if (s->clients[i] != MS_INVALID_SOCKET) { + ms_send_all(s->clients[i], wire, len); + } + } + ms_mutex_unlock(&s->client_lock); + free(wire); +} + +char *csm_mock_server_get_received(csm_mock_server_t *s, unsigned int timeout_ms) { + if (!s) return NULL; + ms_mutex_lock(&s->recv_lock); + if (!s->msg_head) { + ms_cond_wait_ms(&s->recv_cond, &s->recv_lock, timeout_ms); + } + char *out = NULL; + if (s->msg_head) { + ms_msg_t *m = s->msg_head; + s->msg_head = m->next; + if (!s->msg_head) s->msg_tail = NULL; + out = m->text; + free(m); + } + ms_mutex_unlock(&s->recv_lock); + return out; +} diff --git a/SDK/c/tests/mock_server.h b/SDK/c/tests/mock_server.h new file mode 100644 index 0000000..8ac56d0 --- /dev/null +++ b/SDK/c/tests/mock_server.h @@ -0,0 +1,48 @@ +/* mock_server.h - 用于测试的进程内 TCP 服务器,模拟 CSM-TCP-Router。 + * + * 对应 Python `tests/conftest.py` 中的 MockServer 夹具。 + */ +#ifndef CSM_MOCK_SERVER_H +#define CSM_MOCK_SERVER_H + +#include "csm_tcp_router_client.h" + +#include + +typedef struct csm_mock_server csm_mock_server_t; + +/** 创建一个已停止的模拟服务器,绑定到 127.0.0.1; + * 实际端口由操作系统在 csm_mock_server_start() 中分配。 */ +csm_mock_server_t *csm_mock_server_create(void); + +/** 释放(运行中或已停止的)模拟服务器。 */ +void csm_mock_server_destroy(csm_mock_server_t *s); + +/** 绑定到 127.0.0.1 的临时端口,并启动接受连接线程。 */ +int csm_mock_server_start(csm_mock_server_t *s); + +/** 停止接受连接线程并关闭所有客户端连接。 */ +void csm_mock_server_stop(csm_mock_server_t *s); + +/** 返回服务器正在监听的端口(start() 之后有效)。 */ +uint16_t csm_mock_server_port(const csm_mock_server_t *s); + +/** 为精确匹配的命令字符串注册自定义 RESP 回复。 */ +void csm_mock_server_set_response(csm_mock_server_t *s, + const char *cmd_text, + const char *resp_text); + +/** 为精确匹配的命令字符串注册 ERROR 回复。 */ +void csm_mock_server_set_error_response(csm_mock_server_t *s, + const char *cmd_text, + const char *error_text); + +/** 向所有当前连接的客户端推送 STATUS 数据包。 */ +void csm_mock_server_push_status(csm_mock_server_t *s, const char *payload); + +/** 弹出下一条已接收的命令,最多阻塞 *timeout_ms* 毫秒。返回的 + * 字符串由调用者拥有,必须使用 csm_string_free 释放。 + * 超时时返回 NULL。 */ +char *csm_mock_server_get_received(csm_mock_server_t *s, unsigned int timeout_ms); + +#endif /* CSM_MOCK_SERVER_H */ diff --git a/SDK/c/tests/test_client.c b/SDK/c/tests/test_client.c new file mode 100644 index 0000000..2d949cd --- /dev/null +++ b/SDK/c/tests/test_client.c @@ -0,0 +1,51 @@ +/* test_client.c - 客户端对象生命周期的单元测试, + * 无需运行中的模拟服务器。 */ +#include "csm_tcp_router_client.h" +#include "test_harness.h" + +#include + +CSM_TEST(test_client_create_destroy) { + csm_client_t *c = csm_client_create(); + CSM_ASSERT(c != NULL); + CSM_ASSERT_EQ_INT(csm_client_is_connected(c), 0); + csm_client_destroy(c); +} + +CSM_TEST(test_destroy_null_safe) { + csm_client_destroy(NULL); +} + +CSM_TEST(test_send_when_not_connected_returns_connection_error) { + csm_client_t *c = csm_client_create(); + CSM_ASSERT(c != NULL); + csm_command_response_t resp = {0}; + csm_result_t r = csm_client_send_and_wait(c, "Ping", 100, &resp); + CSM_ASSERT_EQ_INT(r, CSM_ERR_CONNECTION); + csm_command_response_dispose(&resp); + csm_client_destroy(c); +} + +CSM_TEST(test_invalid_args_rejected) { + csm_client_t *c = csm_client_create(); + CSM_ASSERT_EQ_INT(csm_client_connect(c, NULL, 1234, 100), CSM_ERR_INVALID); + CSM_ASSERT_EQ_INT(csm_client_send_and_wait(NULL, "x", 100, NULL), CSM_ERR_INVALID); + CSM_ASSERT_EQ_INT(csm_client_send_and_wait(c, NULL, 100, NULL), CSM_ERR_INVALID); + char *out = NULL; + CSM_ASSERT_EQ_INT(csm_client_list_api(c, NULL, &out, 100), CSM_ERR_INVALID); + csm_client_destroy(c); +} + +CSM_TEST(test_wait_for_server_unreachable_times_out) { + /* 选择一个不太可能被使用的任意高端口。 */ + csm_result_t r = csm_client_wait_for_server("127.0.0.1", 1, 200, 50); + CSM_ASSERT_EQ_INT(r, CSM_ERR_TIMEOUT); +} + +CSM_TEST(test_connect_unreachable_returns_connection_error) { + csm_client_t *c = csm_client_create(); + csm_result_t r = csm_client_connect(c, "127.0.0.1", 1, 300); + /* 根据操作系统,返回 CSM_ERR_CONNECTION(拒绝连接)或 CSM_ERR_TIMEOUT。 */ + CSM_ASSERT(r == CSM_ERR_CONNECTION || r == CSM_ERR_TIMEOUT); + csm_client_destroy(c); +} diff --git a/SDK/c/tests/test_harness.h b/SDK/c/tests/test_harness.h new file mode 100644 index 0000000..568347c --- /dev/null +++ b/SDK/c/tests/test_harness.h @@ -0,0 +1,60 @@ +/* test_harness.h - csm-tcp-router-client C SDK 测试使用的极简进程内单元测试框架。 + * + * 测试通过 CSM_TEST() 宏注册自身;test_main.c 中的运行器 + * 通过链接时的 TESTS 数组收集测试,依次执行并打印汇总结果。 + * 失败仅中止当前测试;断言使用 longjmp 回退到运行器。 + */ +#ifndef CSM_TEST_HARNESS_H +#define CSM_TEST_HARNESS_H + +#include +#include +#include + +typedef void (*csm_test_fn)(void); + +typedef struct { + const char *name; + csm_test_fn fn; +} csm_test_t; + +/* 由 test_main.c 提供。 */ +extern jmp_buf csm_test_jmp; +extern int csm_test_failed; +extern int csm_test_assertions; + +#define CSM_TEST_FAIL(...) do { \ + csm_test_failed = 1; \ + fprintf(stderr, " FAIL %s:%d: ", __FILE__, __LINE__); \ + fprintf(stderr, __VA_ARGS__); \ + fprintf(stderr, "\n"); \ + longjmp(csm_test_jmp, 1); \ +} while (0) + +#define CSM_ASSERT(cond) do { \ + csm_test_assertions++; \ + if (!(cond)) CSM_TEST_FAIL("assertion failed: %s", #cond); \ +} while (0) + +#define CSM_ASSERT_EQ_INT(a, b) do { \ + csm_test_assertions++; \ + long long _a = (long long)(a), _b = (long long)(b); \ + if (_a != _b) \ + CSM_TEST_FAIL("expected %lld, got %lld (%s == %s)", \ + _b, _a, #a, #b); \ +} while (0) + +#define CSM_ASSERT_EQ_STR(a, b) do { \ + csm_test_assertions++; \ + const char *_a = (a), *_b = (b); \ + if (_a == NULL || _b == NULL || strcmp(_a, _b) != 0) \ + CSM_TEST_FAIL("expected \"%s\", got \"%s\"", \ + _b ? _b : "(null)", _a ? _a : "(null)"); \ +} while (0) + +/* 定义一个测试函数。运行器通过 CSM_TEST_EXTERN 宏将每个测试声明为 extern, + * 并在测试表中注册。 */ +#define CSM_TEST(name) void name(void) +#define CSM_TEST_EXTERN(name) extern void name(void) + +#endif /* CSM_TEST_HARNESS_H */ diff --git a/SDK/c/tests/test_integration.c b/SDK/c/tests/test_integration.c new file mode 100644 index 0000000..75a3aa2 --- /dev/null +++ b/SDK/c/tests/test_integration.c @@ -0,0 +1,165 @@ +/* test_integration.c - 针对进程内 MockServer 的端到端测试。 */ + +#if !defined(_WIN32) +# ifndef _POSIX_C_SOURCE +# define _POSIX_C_SOURCE 200809L +# endif +#endif + +#include "csm_tcp_router_client.h" +#include "mock_server.h" +#include "test_harness.h" + +#include +#include +#include + +#if defined(_WIN32) +# include +static void it_sleep_ms(unsigned int ms) { Sleep(ms); } +#else +# include +static void it_sleep_ms(unsigned int ms) { + struct timespec ts; ts.tv_sec = ms/1000; ts.tv_nsec = (long)(ms%1000)*1000000L; + nanosleep(&ts, NULL); +} +#endif + +/* 辅助函数:启动服务器 + 连接客户端。 */ +static void it_setup(csm_mock_server_t **out_s, csm_client_t **out_c) { + csm_mock_server_t *s = csm_mock_server_create(); + CSM_ASSERT(s != NULL); + CSM_ASSERT_EQ_INT(csm_mock_server_start(s), 0); + csm_client_t *c = csm_client_create(); + CSM_ASSERT(c != NULL); + csm_result_t r = csm_client_connect(c, "127.0.0.1", + csm_mock_server_port(s), 2000); + CSM_ASSERT_EQ_INT(r, CSM_OK); + *out_s = s; *out_c = c; +} + +static void it_teardown(csm_mock_server_t *s, csm_client_t *c) { + csm_client_destroy(c); + csm_mock_server_destroy(s); +} + +CSM_TEST(it_ping) { + csm_mock_server_t *s = NULL; csm_client_t *c = NULL; + it_setup(&s, &c); + double ms = 0; + csm_result_t r = csm_client_ping(c, 1000, &ms); + CSM_ASSERT_EQ_INT(r, CSM_OK); + CSM_ASSERT(ms >= 0); + it_teardown(s, c); +} + +CSM_TEST(it_list_modules) { + csm_mock_server_t *s = NULL; csm_client_t *c = NULL; + it_setup(&s, &c); + char *txt = NULL; + csm_result_t r = csm_client_list_modules(c, &txt, 1000); + CSM_ASSERT_EQ_INT(r, CSM_OK); + CSM_ASSERT_EQ_STR(txt, "AI\nDIO\nSystem"); + csm_string_free(txt); + it_teardown(s, c); +} + +CSM_TEST(it_list_api_includes_module_name) { + csm_mock_server_t *s = NULL; csm_client_t *c = NULL; + it_setup(&s, &c); + char *txt = NULL; + csm_result_t r = csm_client_list_api(c, "DAQmx", &txt, 1000); + CSM_ASSERT_EQ_INT(r, CSM_OK); + CSM_ASSERT(strstr(txt, "DAQmx") != NULL); + csm_string_free(txt); + it_teardown(s, c); +} + +CSM_TEST(it_send_and_wait_custom_response) { + csm_mock_server_t *s = NULL; csm_client_t *c = NULL; + it_setup(&s, &c); + csm_mock_server_set_response(s, "API: Read -@ DAQmx", "42"); + csm_command_response_t resp = {0}; + csm_result_t r = csm_client_send_and_wait(c, "API: Read -@ DAQmx", 1000, &resp); + CSM_ASSERT_EQ_INT(r, CSM_OK); + CSM_ASSERT_EQ_INT(resp.raw_len, 2); + CSM_ASSERT(memcmp(resp.raw, "42", 2) == 0); + csm_command_response_dispose(&resp); + it_teardown(s, c); +} + +CSM_TEST(it_server_error_propagated) { + csm_mock_server_t *s = NULL; csm_client_t *c = NULL; + it_setup(&s, &c); + csm_mock_server_set_error_response(s, "Bad", "[Error: 42] bad command"); + csm_command_response_t resp = {0}; + csm_result_t r = csm_client_send_and_wait(c, "Bad", 1000, &resp); + CSM_ASSERT_EQ_INT(r, CSM_ERR_SERVER); + csm_server_error_t err = {{0}, {0}}; + CSM_ASSERT_EQ_INT(csm_client_last_server_error(c, &err), CSM_OK); + CSM_ASSERT_EQ_STR(err.code, "42"); + CSM_ASSERT(strstr(err.message, "bad command") != NULL); + csm_command_response_dispose(&resp); + it_teardown(s, c); +} + +CSM_TEST(it_post_async_handshake) { + csm_mock_server_t *s = NULL; csm_client_t *c = NULL; + it_setup(&s, &c); + csm_result_t r = csm_client_post(c, "API: Start -> DAQmx", 1000); + CSM_ASSERT_EQ_INT(r, CSM_OK); + char *got = csm_mock_server_get_received(s, 500); + CSM_ASSERT(got != NULL); + CSM_ASSERT_EQ_STR(got, "API: Start -> DAQmx"); + csm_string_free(got); + it_teardown(s, c); +} + +static void it_status_cb(const csm_status_notification_t *n, void *ud) { + int *count = (int *)ud; + (*count)++; + /* 完整性检查已解析的字段。 */ + (void)n; +} + +CSM_TEST(it_subscribe_status_invokes_callback) { + csm_mock_server_t *s = NULL; csm_client_t *c = NULL; + it_setup(&s, &c); + int count = 0; + csm_result_t r = csm_client_subscribe_status(c, "Status", "DAQmx", + it_status_cb, &count, 1000); + CSM_ASSERT_EQ_INT(r, CSM_OK); + /* 从已接收队列中消耗握手数据。 */ + char *cmd = csm_mock_server_get_received(s, 500); + csm_string_free(cmd); + /* 推送一条匹配订阅的 STATUS 通知。 */ + csm_mock_server_push_status(s, "Status >> 1.23 <- DAQmx"); + /* 等待回调(最多轮询 1 秒)。 */ + for (int i = 0; i < 100 && count == 0; ++i) it_sleep_ms(10); + CSM_ASSERT(count >= 1); + + /* 同一通知也应可通过轮询获取。 */ + csm_status_notification_t n = {0}; + r = csm_client_poll_status(c, &n, 500); + CSM_ASSERT_EQ_INT(r, CSM_OK); + CSM_ASSERT_EQ_STR(n.status_name, "Status"); + CSM_ASSERT_EQ_STR(n.module_name, "DAQmx"); + CSM_ASSERT_EQ_STR(n.data, "1.23"); + csm_status_notification_dispose(&n); + + csm_client_unsubscribe_status(c, "Status", "DAQmx", 1000); + it_teardown(s, c); +} + +CSM_TEST(it_disconnect_unblocks_waiters) { + csm_mock_server_t *s = NULL; csm_client_t *c = NULL; + it_setup(&s, &c); + /* 发送一个没有预置响应的命令;模拟服务器返回 CMD_RESP。 */ + /* 本测试中直接立即断开连接并验证后续发送失败。 */ + csm_client_disconnect(c); + csm_command_response_t resp = {0}; + csm_result_t r = csm_client_send_and_wait(c, "Ping", 200, &resp); + CSM_ASSERT_EQ_INT(r, CSM_ERR_CONNECTION); + csm_command_response_dispose(&resp); + it_teardown(s, c); +} diff --git a/SDK/c/tests/test_main.c b/SDK/c/tests/test_main.c new file mode 100644 index 0000000..5fcdad9 --- /dev/null +++ b/SDK/c/tests/test_main.c @@ -0,0 +1,93 @@ +/* test_main.c - C SDK 测试套件的运行器。 */ +#include "test_harness.h" + +#include +#include +#include + +jmp_buf csm_test_jmp; +int csm_test_failed = 0; +int csm_test_assertions = 0; + +/* --- test_protocol.c --- */ +CSM_TEST_EXTERN(test_header_size_constant); +CSM_TEST_EXTERN(test_encode_decode_roundtrip); +CSM_TEST_EXTERN(test_encode_empty_payload); +CSM_TEST_EXTERN(test_encode_buffer_too_small); +CSM_TEST_EXTERN(test_decode_header_bad_size); +CSM_TEST_EXTERN(test_parse_packet_unknown_type_maps_to_info); +CSM_TEST_EXTERN(test_parse_packet_length_mismatch); +CSM_TEST_EXTERN(test_result_str_known); + +/* --- test_client.c --- */ +CSM_TEST_EXTERN(test_client_create_destroy); +CSM_TEST_EXTERN(test_destroy_null_safe); +CSM_TEST_EXTERN(test_send_when_not_connected_returns_connection_error); +CSM_TEST_EXTERN(test_invalid_args_rejected); +CSM_TEST_EXTERN(test_wait_for_server_unreachable_times_out); +CSM_TEST_EXTERN(test_connect_unreachable_returns_connection_error); + +/* --- test_integration.c --- */ +CSM_TEST_EXTERN(it_ping); +CSM_TEST_EXTERN(it_list_modules); +CSM_TEST_EXTERN(it_list_api_includes_module_name); +CSM_TEST_EXTERN(it_send_and_wait_custom_response); +CSM_TEST_EXTERN(it_server_error_propagated); +CSM_TEST_EXTERN(it_post_async_handshake); +CSM_TEST_EXTERN(it_subscribe_status_invokes_callback); +CSM_TEST_EXTERN(it_disconnect_unblocks_waiters); + +static const csm_test_t TESTS[] = { + {"test_header_size_constant", test_header_size_constant}, + {"test_encode_decode_roundtrip", test_encode_decode_roundtrip}, + {"test_encode_empty_payload", test_encode_empty_payload}, + {"test_encode_buffer_too_small", test_encode_buffer_too_small}, + {"test_decode_header_bad_size", test_decode_header_bad_size}, + {"test_parse_packet_unknown_type_maps_to_info", test_parse_packet_unknown_type_maps_to_info}, + {"test_parse_packet_length_mismatch", test_parse_packet_length_mismatch}, + {"test_result_str_known", test_result_str_known}, + {"test_client_create_destroy", test_client_create_destroy}, + {"test_destroy_null_safe", test_destroy_null_safe}, + {"test_send_when_not_connected_returns_connection_error", test_send_when_not_connected_returns_connection_error}, + {"test_invalid_args_rejected", test_invalid_args_rejected}, + {"test_wait_for_server_unreachable_times_out", test_wait_for_server_unreachable_times_out}, + {"test_connect_unreachable_returns_connection_error", test_connect_unreachable_returns_connection_error}, + {"it_ping", it_ping}, + {"it_list_modules", it_list_modules}, + {"it_list_api_includes_module_name", it_list_api_includes_module_name}, + {"it_send_and_wait_custom_response", it_send_and_wait_custom_response}, + {"it_server_error_propagated", it_server_error_propagated}, + {"it_post_async_handshake", it_post_async_handshake}, + {"it_subscribe_status_invokes_callback", it_subscribe_status_invokes_callback}, + {"it_disconnect_unblocks_waiters", it_disconnect_unblocks_waiters}, +}; + +int main(int argc, char **argv) { + const char *only = (argc > 1) ? argv[1] : NULL; + /* `volatile` 确保这些变量在各个测试体内的失败断言执行 longjmp 后仍然存活。 */ + volatile int passed = 0, failed = 0, skipped = 0; + volatile int total_assertions = 0; + size_t n = sizeof(TESTS) / sizeof(TESTS[0]); + volatile size_t i = 0; + for (; i < n; ++i) { + if (only && strcmp(only, TESTS[i].name) != 0) { skipped++; continue; } + printf("[RUN ] %s\n", TESTS[i].name); + csm_test_failed = 0; + int before = csm_test_assertions; + if (setjmp(csm_test_jmp) == 0) { + TESTS[i].fn(); + } + int delta = csm_test_assertions - before; + total_assertions += delta; + if (csm_test_failed) { + printf("[FAIL] %s (%d asserts)\n", TESTS[i].name, delta); + failed++; + } else { + printf("[ OK ] %s (%d asserts)\n", TESTS[i].name, delta); + passed++; + } + } + printf("\nResults: %d passed, %d failed, %d skipped (%d assertions)\n", + passed, failed, skipped, total_assertions); + return failed == 0 ? 0 : 1; +} diff --git a/SDK/c/tests/test_protocol.c b/SDK/c/tests/test_protocol.c new file mode 100644 index 0000000..a8c9e8a --- /dev/null +++ b/SDK/c/tests/test_protocol.c @@ -0,0 +1,84 @@ +/* test_protocol.c - 协议编解码的单元测试。 */ +#include "csm_tcp_router_client.h" +#include "test_harness.h" + +#include + +CSM_TEST(test_header_size_constant) { + CSM_ASSERT_EQ_INT(CSM_HEADER_SIZE, 8); + CSM_ASSERT_EQ_INT(CSM_PROTOCOL_VERSION, 0x01); +} + +CSM_TEST(test_encode_decode_roundtrip) { + const char *payload = "Hello"; + uint8_t buf[64]; + size_t out_len = 0; + csm_result_t r = csm_encode_packet(payload, 5, CSM_PT_CMD, 0, 0, + buf, sizeof(buf), &out_len); + CSM_ASSERT_EQ_INT(r, CSM_OK); + CSM_ASSERT_EQ_INT(out_len, 8 + 5); + + /* 头部字节:大端序长度、版本、类型、flag1、flag2。 */ + CSM_ASSERT_EQ_INT(buf[0], 0); + CSM_ASSERT_EQ_INT(buf[1], 0); + CSM_ASSERT_EQ_INT(buf[2], 0); + CSM_ASSERT_EQ_INT(buf[3], 5); + CSM_ASSERT_EQ_INT(buf[4], CSM_PROTOCOL_VERSION); + CSM_ASSERT_EQ_INT(buf[5], CSM_PT_CMD); + CSM_ASSERT(memcmp(buf + 8, "Hello", 5) == 0); + + uint32_t len; uint8_t ver, type, f1, f2; + r = csm_decode_header(buf, 8, &len, &ver, &type, &f1, &f2); + CSM_ASSERT_EQ_INT(r, CSM_OK); + CSM_ASSERT_EQ_INT(len, 5); + CSM_ASSERT_EQ_INT(ver, 1); + CSM_ASSERT_EQ_INT(type, CSM_PT_CMD); +} + +CSM_TEST(test_encode_empty_payload) { + uint8_t buf[16]; + size_t out_len = 0; + csm_result_t r = csm_encode_packet(NULL, 0, CSM_PT_INFO, 0, 0, + buf, sizeof(buf), &out_len); + CSM_ASSERT_EQ_INT(r, CSM_OK); + CSM_ASSERT_EQ_INT(out_len, 8); +} + +CSM_TEST(test_encode_buffer_too_small) { + uint8_t buf[4]; + size_t out_len = 0; + csm_result_t r = csm_encode_packet("X", 1, CSM_PT_CMD, 0, 0, + buf, sizeof(buf), &out_len); + CSM_ASSERT_EQ_INT(r, CSM_ERR_INVALID); +} + +CSM_TEST(test_decode_header_bad_size) { + uint8_t hdr[4] = {0}; + csm_result_t r = csm_decode_header(hdr, 4, NULL, NULL, NULL, NULL, NULL); + CSM_ASSERT_EQ_INT(r, CSM_ERR_PROTOCOL); +} + +CSM_TEST(test_parse_packet_unknown_type_maps_to_info) { + uint8_t hdr[8] = {0,0,0,3, 0x01, 0xFE /* unknown */, 0, 0}; + uint8_t body[3] = {'a','b','c'}; + csm_packet_t pkt = {0}; + csm_result_t r = csm_parse_packet(hdr, 8, body, 3, &pkt); + CSM_ASSERT_EQ_INT(r, CSM_OK); + CSM_ASSERT_EQ_INT(pkt.type, CSM_PT_INFO); + CSM_ASSERT_EQ_INT(pkt.data_len, 3); + CSM_ASSERT(memcmp(pkt.data, "abc", 3) == 0); + csm_packet_dispose(&pkt); +} + +CSM_TEST(test_parse_packet_length_mismatch) { + uint8_t hdr[8] = {0,0,0,5, 0x01, CSM_PT_RESP, 0, 0}; + uint8_t body[3] = {'a','b','c'}; + csm_packet_t pkt = {0}; + csm_result_t r = csm_parse_packet(hdr, 8, body, 3, &pkt); + CSM_ASSERT_EQ_INT(r, CSM_ERR_PROTOCOL); +} + +CSM_TEST(test_result_str_known) { + CSM_ASSERT(strcmp(csm_result_str(CSM_OK), "OK") == 0); + CSM_ASSERT(strstr(csm_result_str(CSM_ERR_TIMEOUT), "imeout") != NULL); +} diff --git a/SDK/c/vs2026/README.md b/SDK/c/vs2026/README.md new file mode 100644 index 0000000..77376d2 --- /dev/null +++ b/SDK/c/vs2026/README.md @@ -0,0 +1,61 @@ +# Visual Studio 2026 build files + +This directory contains a Visual Studio 2026 (VS 18) solution and projects +for building the C SDK and its test suite directly from the IDE on +Windows. The CMake build at `SDK/c/CMakeLists.txt` remains the +authoritative cross-platform build; these project files are provided as a +convenience for Windows developers who prefer the VS IDE. + +## Layout + +``` +vs2026/ +├── csm_tcp_router_client.sln – solution file +├── csm_tcp_router_client/ +│ └── csm_tcp_router_client.vcxproj – static library project +└── csm_tcp_router_client.tests/ + └── csm_tcp_router_client.tests.vcxproj – test executable +``` + +## Toolset + +| Setting | Value | +|-------------------|-----------------------------------------| +| Solution format | Visual Studio 18 (2026) | +| Platform toolset | `v144` (Visual Studio 2026 C/C++) | +| C language | `/std:c11` (`stdc11` MSBuild metadata) | +| Configurations | Debug, Release | +| Platforms | Win32 (x86), x64 | +| Runtime | `/MT[d]` (statically linked CRT) | +| Subsystem (tests) | Console | +| Linked libraries | `Ws2_32.lib` | + +## Building + +Open `csm_tcp_router_client.sln` in Visual Studio 2026 and build the +solution (Ctrl+Shift+B), or from a developer command prompt: + +```cmd +msbuild csm_tcp_router_client.sln ^ + /p:Configuration=Release /p:Platform=x64 +``` + +## Running tests + +After building, run the test executable directly: + +```cmd +build\x64\Release\csm_tcp_router_client.tests\csm_tcp_router_client.tests.exe +``` + +Exit code `0` indicates all tests passed; any other exit code indicates +one or more failures (failed test names are printed to stderr). + +## Why VS2026 and not an older version? + +The user explicitly requested VS2026 (the latest Visual Studio at the +time the SDK was added). The project files use the new `v144` platform +toolset shipped with VS 2026; older VS versions will refuse to load +them. If you need to build with an older VS, prefer the `CMakeLists.txt` +in the SDK root – it works with every supported MSVC version (and on +Linux / macOS). diff --git a/SDK/c/vs2026/csm_tcp_router_client.sln b/SDK/c/vs2026/csm_tcp_router_client.sln new file mode 100644 index 0000000..08a0b08 --- /dev/null +++ b/SDK/c/vs2026/csm_tcp_router_client.sln @@ -0,0 +1,43 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.0.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "csm_tcp_router_client", "csm_tcp_router_client\csm_tcp_router_client.vcxproj", "{DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "csm_tcp_router_client.tests", "csm_tcp_router_client.tests\csm_tcp_router_client.tests.vcxproj", "{92C7CD36-6916-46D6-803F-137C11E423CF}" + ProjectSection(ProjectDependencies) = postProject + {DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD} = {DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD} + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + Debug|Win32 = Debug|Win32 + Release|Win32 = Release|Win32 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD}.Debug|x64.ActiveCfg = Debug|x64 + {DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD}.Debug|x64.Build.0 = Debug|x64 + {DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD}.Release|x64.ActiveCfg = Release|x64 + {DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD}.Release|x64.Build.0 = Release|x64 + {DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD}.Debug|Win32.ActiveCfg = Debug|Win32 + {DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD}.Debug|Win32.Build.0 = Debug|Win32 + {DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD}.Release|Win32.ActiveCfg = Release|Win32 + {DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD}.Release|Win32.Build.0 = Release|Win32 + {92C7CD36-6916-46D6-803F-137C11E423CF}.Debug|x64.ActiveCfg = Debug|x64 + {92C7CD36-6916-46D6-803F-137C11E423CF}.Debug|x64.Build.0 = Debug|x64 + {92C7CD36-6916-46D6-803F-137C11E423CF}.Release|x64.ActiveCfg = Release|x64 + {92C7CD36-6916-46D6-803F-137C11E423CF}.Release|x64.Build.0 = Release|x64 + {92C7CD36-6916-46D6-803F-137C11E423CF}.Debug|Win32.ActiveCfg = Debug|Win32 + {92C7CD36-6916-46D6-803F-137C11E423CF}.Debug|Win32.Build.0 = Debug|Win32 + {92C7CD36-6916-46D6-803F-137C11E423CF}.Release|Win32.ActiveCfg = Release|Win32 + {92C7CD36-6916-46D6-803F-137C11E423CF}.Release|Win32.Build.0 = Release|Win32 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {1405610E-2603-4EE0-A392-54886017A89E} + EndGlobalSection +EndGlobal diff --git a/SDK/c/vs2026/csm_tcp_router_client.tests/csm_tcp_router_client.tests.vcxproj b/SDK/c/vs2026/csm_tcp_router_client.tests/csm_tcp_router_client.tests.vcxproj new file mode 100644 index 0000000..9ea1c0b --- /dev/null +++ b/SDK/c/vs2026/csm_tcp_router_client.tests/csm_tcp_router_client.tests.vcxproj @@ -0,0 +1,94 @@ + + + + + + Debug + Win32 + + + Release + Win32 + + + Debug + x64 + + + Release + x64 + + + + + 18.0 + {92C7CD36-6916-46D6-803F-137C11E423CF} + csm_tcp_router_client_tests + Win32Proj + 10.0 + + + + + + Application + v144 + Unicode + true + false + + + + + + $(SolutionDir)build\$(Platform)\$(Configuration)\ + $(SolutionDir)build\$(Platform)\$(Configuration)\$(ProjectName)\ + + + + + $(ProjectDir)..\..\include;$(ProjectDir)..\..\tests;%(AdditionalIncludeDirectories) + WIN32;_CONSOLE;_CRT_SECURE_NO_WARNINGS;%(PreprocessorDefinitions) + _DEBUG;%(PreprocessorDefinitions) + NDEBUG;%(PreprocessorDefinitions) + Level4 + true + true + CompileAsC + stdc11 + MultiThreadedDebug + MultiThreaded + Disabled + MaxSpeed + + + Console + Ws2_32.lib;%(AdditionalDependencies) + true + + + + + + + + + + + + + + + + + {DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD} + + + + + diff --git a/SDK/c/vs2026/csm_tcp_router_client.tests/csm_tcp_router_client.tests.vcxproj.filters b/SDK/c/vs2026/csm_tcp_router_client.tests/csm_tcp_router_client.tests.vcxproj.filters new file mode 100644 index 0000000..8c83bd3 --- /dev/null +++ b/SDK/c/vs2026/csm_tcp_router_client.tests/csm_tcp_router_client.tests.vcxproj.filters @@ -0,0 +1,22 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + + + {93995380-89BD-4B04-88EB-625FBE52EBFB} + + + + Source Files + Source Files + Source Files + Source Files + Source Files + + + Header Files + Header Files + + diff --git a/SDK/c/vs2026/csm_tcp_router_client/csm_tcp_router_client.vcxproj b/SDK/c/vs2026/csm_tcp_router_client/csm_tcp_router_client.vcxproj new file mode 100644 index 0000000..2684b41 --- /dev/null +++ b/SDK/c/vs2026/csm_tcp_router_client/csm_tcp_router_client.vcxproj @@ -0,0 +1,87 @@ + + + + + + Debug + Win32 + + + Release + Win32 + + + Debug + x64 + + + Release + x64 + + + + + 18.0 + {DAE3271A-FD3C-4E9C-A4CC-54DF2EB32AFD} + csm_tcp_router_client + Win32Proj + 10.0 + + + + + + StaticLibrary + v144 + Unicode + true + false + true + + + + + + $(SolutionDir)build\$(Platform)\$(Configuration)\ + $(SolutionDir)build\$(Platform)\$(Configuration)\$(ProjectName)\ + + + + + $(ProjectDir)..\..\include;%(AdditionalIncludeDirectories) + WIN32;_WINDOWS;_CRT_SECURE_NO_WARNINGS;CSM_BUILD_LIBRARY;%(PreprocessorDefinitions) + _DEBUG;%(PreprocessorDefinitions) + NDEBUG;%(PreprocessorDefinitions) + Level4 + true + true + CompileAsC + stdc11 + MultiThreadedDebug + MultiThreaded + Disabled + MaxSpeed + true + true + + + Ws2_32.lib;%(AdditionalDependencies) + + + + + + + + + + + + diff --git a/SDK/c/vs2026/csm_tcp_router_client/csm_tcp_router_client.vcxproj.filters b/SDK/c/vs2026/csm_tcp_router_client/csm_tcp_router_client.vcxproj.filters new file mode 100644 index 0000000..810a992 --- /dev/null +++ b/SDK/c/vs2026/csm_tcp_router_client/csm_tcp_router_client.vcxproj.filters @@ -0,0 +1,23 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + c;cpp + + + {93995380-89BD-4B04-88EB-625FBE52EBFB} + h + + + + + Source Files + + + + + Header Files + + + diff --git a/SDK/csharp/CHANGELOG.md b/SDK/csharp/CHANGELOG.md new file mode 100644 index 0000000..14506f9 --- /dev/null +++ b/SDK/csharp/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +All notable changes to `CsmTcpRouter.Client` are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [Unreleased] + +--- + +## [0.1.0] – 2026-04-27 + +### Added + +- Initial release of the C# / .NET client SDK for the CSM-TCP-Router LabVIEW + server. +- Single-file implementation in `src/CsmTcpRouter/CsmTcpRouter.cs`. +- Multi-targets `netstandard2.0` (for .NET Framework 4.6.2+, .NET Core 3.1+, + .NET 5/6/7/8/9) and `net8.0`. Zero third-party runtime dependencies. +- Public types: + - `TcpRouterClient` – synchronous and asynchronous APIs: + `Connect/ConnectAsync`, `Disconnect`, `WaitForServer/WaitForServerAsync`, + `SendAndWait/SendAndWaitAsync`, `Post/PostAsync`, + `PostNoReply/PostNoReplyAsync`, `Ping/PingAsync`, `ListModules`, + `ListApi`, `ListStates`, `Help`, `SubscribeStatus/UnsubscribeStatus`, + `RegisterAsyncCallback/UnregisterAsyncCallback`. + - `PacketType` enum and data models `Packet`, `CommandResponse`, + `AsyncResponse`, `StatusNotification`. + - Exception hierarchy: `CsmTcpRouterException`, `RouterConnectionException`, + `RouterTimeoutException`, `ProtocolException`, `ServerException`. +- xUnit test suite (51 tests) covering protocol codec, error parsing, model + parsing, and end-to-end client behaviour against an in-process MockServer. +- Runnable console example `examples/BasicUsage/`. +- VS 2022 / 2026 compatible solution `CsmTcpRouter.sln`. +- GitHub Actions workflow `.github/workflows/CSharp_SDK.yml` for build, test, + pack, and conditional NuGet publish on `csharp-sdk-v*` tag pushes. +- English (`README.md`) and Chinese (`README.zh-cn.md`) documentation. diff --git a/SDK/csharp/CsmTcpRouter.sln b/SDK/csharp/CsmTcpRouter.sln new file mode 100644 index 0000000..0f53351 --- /dev/null +++ b/SDK/csharp/CsmTcpRouter.sln @@ -0,0 +1,88 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CsmTcpRouter", "src\CsmTcpRouter\CsmTcpRouter.csproj", "{AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CsmTcpRouter.Tests", "tests\CsmTcpRouter.Tests\CsmTcpRouter.Tests.csproj", "{1EFBB3D5-6452-41A9-8501-897D421B33DB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BasicUsage", "examples\BasicUsage\BasicUsage.csproj", "{4DD90760-BF7D-45EB-BAE9-E47F65B053C9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClientConsole", "examples\ClientConsole\ClientConsole.csproj", "{7160908E-E970-4A92-95B4-462403BD0EE8}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}.Debug|x64.ActiveCfg = Debug|Any CPU + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}.Debug|x64.Build.0 = Debug|Any CPU + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}.Debug|x86.ActiveCfg = Debug|Any CPU + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}.Debug|x86.Build.0 = Debug|Any CPU + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}.Release|Any CPU.Build.0 = Release|Any CPU + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}.Release|x64.ActiveCfg = Release|Any CPU + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}.Release|x64.Build.0 = Release|Any CPU + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}.Release|x86.ActiveCfg = Release|Any CPU + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C}.Release|x86.Build.0 = Release|Any CPU + {1EFBB3D5-6452-41A9-8501-897D421B33DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1EFBB3D5-6452-41A9-8501-897D421B33DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1EFBB3D5-6452-41A9-8501-897D421B33DB}.Debug|x64.ActiveCfg = Debug|Any CPU + {1EFBB3D5-6452-41A9-8501-897D421B33DB}.Debug|x64.Build.0 = Debug|Any CPU + {1EFBB3D5-6452-41A9-8501-897D421B33DB}.Debug|x86.ActiveCfg = Debug|Any CPU + {1EFBB3D5-6452-41A9-8501-897D421B33DB}.Debug|x86.Build.0 = Debug|Any CPU + {1EFBB3D5-6452-41A9-8501-897D421B33DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1EFBB3D5-6452-41A9-8501-897D421B33DB}.Release|Any CPU.Build.0 = Release|Any CPU + {1EFBB3D5-6452-41A9-8501-897D421B33DB}.Release|x64.ActiveCfg = Release|Any CPU + {1EFBB3D5-6452-41A9-8501-897D421B33DB}.Release|x64.Build.0 = Release|Any CPU + {1EFBB3D5-6452-41A9-8501-897D421B33DB}.Release|x86.ActiveCfg = Release|Any CPU + {1EFBB3D5-6452-41A9-8501-897D421B33DB}.Release|x86.Build.0 = Release|Any CPU + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9}.Debug|x64.ActiveCfg = Debug|Any CPU + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9}.Debug|x64.Build.0 = Debug|Any CPU + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9}.Debug|x86.ActiveCfg = Debug|Any CPU + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9}.Debug|x86.Build.0 = Debug|Any CPU + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9}.Release|Any CPU.Build.0 = Release|Any CPU + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9}.Release|x64.ActiveCfg = Release|Any CPU + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9}.Release|x64.Build.0 = Release|Any CPU + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9}.Release|x86.ActiveCfg = Release|Any CPU + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9}.Release|x86.Build.0 = Release|Any CPU + {7160908E-E970-4A92-95B4-462403BD0EE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7160908E-E970-4A92-95B4-462403BD0EE8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7160908E-E970-4A92-95B4-462403BD0EE8}.Debug|x64.ActiveCfg = Debug|Any CPU + {7160908E-E970-4A92-95B4-462403BD0EE8}.Debug|x64.Build.0 = Debug|Any CPU + {7160908E-E970-4A92-95B4-462403BD0EE8}.Debug|x86.ActiveCfg = Debug|Any CPU + {7160908E-E970-4A92-95B4-462403BD0EE8}.Debug|x86.Build.0 = Debug|Any CPU + {7160908E-E970-4A92-95B4-462403BD0EE8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7160908E-E970-4A92-95B4-462403BD0EE8}.Release|Any CPU.Build.0 = Release|Any CPU + {7160908E-E970-4A92-95B4-462403BD0EE8}.Release|x64.ActiveCfg = Release|Any CPU + {7160908E-E970-4A92-95B4-462403BD0EE8}.Release|x64.Build.0 = Release|Any CPU + {7160908E-E970-4A92-95B4-462403BD0EE8}.Release|x86.ActiveCfg = Release|Any CPU + {7160908E-E970-4A92-95B4-462403BD0EE8}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {AA5DEDA8-96DD-4A05-8029-57ED1E7DCE1C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {1EFBB3D5-6452-41A9-8501-897D421B33DB} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {4DD90760-BF7D-45EB-BAE9-E47F65B053C9} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + {7160908E-E970-4A92-95B4-462403BD0EE8} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F} + EndGlobalSection +EndGlobal diff --git a/SDK/csharp/LICENSE b/SDK/csharp/LICENSE new file mode 100644 index 0000000..78e1cd2 --- /dev/null +++ b/SDK/csharp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NEVSTOP-LAB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SDK/csharp/README.md b/SDK/csharp/README.md new file mode 100644 index 0000000..99ce624 --- /dev/null +++ b/SDK/csharp/README.md @@ -0,0 +1,189 @@ +# CsmTcpRouter.Client + +[![NuGet](https://img.shields.io/nuget/v/CsmTcpRouter.Client.svg)](https://www.nuget.org/packages/CsmTcpRouter.Client/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![CI](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/CSharp_SDK.yml/badge.svg)](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/CSharp_SDK.yml) + +C# / .NET client SDK for the [CSM-TCP-Router](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App) LabVIEW server. + +CSM-TCP-Router exposes a LabVIEW [Communicable State Machine (CSM)](https://github.com/NEVSTOP-LAB/Communicable-State-Machine) application over TCP so that any TCP client — including .NET applications, test harnesses, or CI pipelines — can send commands and receive responses without touching the LabVIEW code. + +> 📖 [中文文档 README.zh-cn.md](README.zh-cn.md) + +The entire SDK lives in a single source file: [`src/CsmTcpRouter/CsmTcpRouter.cs`](src/CsmTcpRouter/CsmTcpRouter.cs). + +--- + +## Installation + +```bash +dotnet add package CsmTcpRouter.Client +``` + +Targets `netstandard2.0` and `net8.0`. No third-party runtime dependencies — only the .NET BCL. + +Supported platforms include .NET Framework 4.6.2+, .NET Core 3.1+, and .NET 5/6/7/8/9. + +--- + +## Quickstart + +### Synchronous client + +```csharp +using CsmTcpRouter; + +using var client = new TcpRouterClient(); +client.Connect("localhost", 30007); + +// List all loaded CSM modules +Console.WriteLine(client.ListModules()); + +// Send a synchronous command and wait for the response +var resp = client.SendAndWait("API: Read -@ DAQmx"); +Console.WriteLine(resp.Text); + +// Ping the server +var (ok, elapsed) = client.Ping(); +Console.WriteLine($"Ping: {ok}, latency={elapsed.TotalMilliseconds:F1} ms"); +``` + +### Asynchronous client + +```csharp +using CsmTcpRouter; + +using var client = new TcpRouterClient(); +await client.ConnectAsync("localhost", 30007); + +Console.WriteLine(await client.ListModulesAsync()); +var resp = await client.SendAndWaitAsync("API: Read -@ DAQmx"); +Console.WriteLine(resp.Text); +``` + +### Subscribe to status broadcasts + +```csharp +client.SubscribeStatus("Status", "AI", notif => +{ + Console.WriteLine($"{notif.StatusName} = {notif.Data}"); +}); + +// ... later +client.UnsubscribeStatus("Status", "AI"); +``` + +### Async-response callbacks + +```csharp +client.RegisterAsyncCallback("API: Start Sampling -> DAQmx", ar => +{ + Console.WriteLine($"Async-resp: {ar.Text}"); +}); + +client.Post("API: Start Sampling -> DAQmx"); +``` + +See [`examples/BasicUsage/Program.cs`](examples/BasicUsage/Program.cs) for a complete runnable example. + +--- + +## API reference + +### `TcpRouterClient` + +| Method | Description | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `Connect / ConnectAsync(host, port, timeout?)` | Open a TCP connection. | +| `Disconnect()` | Close the connection and unblock any pending waiters with `RouterConnectionException`. | +| `Connected` | `true` while the underlying socket is open. | +| `WaitForServer / WaitForServerAsync(...)` | Poll `host:port` until a connection succeeds or the timeout elapses. | +| `SendAndWait / SendAndWaitAsync(cmd, timeout?)` | Send a synchronous command (`-@`) and block until the `RESP` packet arrives. Returns `CommandResponse`. | +| `Post / PostAsync(cmd, timeout?)` | Send an async command (`->`) and wait for the `CMD_RESP` handshake. | +| `PostNoReply / PostNoReplyAsync(cmd, timeout?)` | Send a no-reply async command (`->\|`) and wait for the `CMD_RESP` handshake. | +| `Ping / PingAsync(timeout?)` | Round-trip latency check; returns `(bool ok, TimeSpan elapsed)`. | +| `ListModules / ListApi / ListStates / Help(...)` | Built-in router-management helpers. | +| `SubscribeStatus / UnsubscribeStatus(...)` | Register / cancel a `STATUS` (or `INTERRUPT`) broadcast subscription. | +| `RegisterAsyncCallback / UnregisterAsyncCallback(...)` | Register / remove a callback dispatched for matching `ASYNC_RESP` packets. | +| `AsyncResponseQueue`, `StatusQueue` | Polling alternatives to callbacks (`ConcurrentQueue<>`). | + +### Models + +* `PacketType` (enum) — wire-level packet types as in the protocol v0 spec. +* `Packet`, `CommandResponse`, `AsyncResponse`, `StatusNotification` — decoded payloads. + +### Exceptions + +* `CsmTcpRouterException` — base class. +* `RouterConnectionException` — connect / send / disconnect failures. +* `RouterTimeoutException` — synchronous waiter timeout. +* `ProtocolException` — invalid wire framing. +* `ServerException` — server returned an `ERROR` packet (`Code` and `ServerMessage`). + +--- + +## Protocol + +CSM-TCP-Router protocol v0 uses an 8-byte header (big-endian) followed by an arbitrary payload: + +``` +| Data Length (4B) | Version (1B = 0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | ++------------------------ Header (8B) ----------------------------+ +``` + +Packet types: + +| Value | Name | Direction | Use | +| ----- | ------------ | ----------------- | ---------------------------------------------- | +| 0x00 | `Info` | server → client | Welcome / goodbye message. | +| 0x01 | `Error` | server → client | Error reply (`[Error: ] `). | +| 0x02 | `Cmd` | client → server | Command from the client. | +| 0x03 | `CmdResp` | server → client | Async / no-reply / subscribe handshake. | +| 0x04 | `Resp` | server → client | Synchronous command response. | +| 0x05 | `AsyncResp` | server → client | Asynchronous command response (echoes cmd). | +| 0x06 | `Status` | server → client | Status broadcast from a subscribed module. | +| 0x07 | `Interrupt` | server → client | Interrupt broadcast from a subscribed module. | + +--- + +## Building from source + +```bash +# Restore + build the whole solution +dotnet build SDK/csharp/CsmTcpRouter.sln -c Release + +# Run the unit + integration test suite +dotnet test SDK/csharp/CsmTcpRouter.Tests/CsmTcpRouter.Tests.csproj -c Release + +# Build the NuGet package +dotnet pack SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.csproj -c Release -o nupkg +``` + +The solution opens directly in **Visual Studio 2022 / 2026** (or **JetBrains Rider**); SDK-style projects are forward-compatible with all current Visual Studio versions. + +--- + +## Project layout + +``` +SDK/csharp/ +├── CsmTcpRouter.sln +├── README.md / README.zh-cn.md / CHANGELOG.md / LICENSE +├── src/CsmTcpRouter/ ← single-file SDK +│ ├── CsmTcpRouter.cs +│ └── CsmTcpRouter.csproj +├── tests/CsmTcpRouter.Tests/ ← xUnit test project +│ ├── ProtocolTests.cs +│ ├── ClientIntegrationTests.cs +│ ├── MockServer.cs +│ └── CsmTcpRouter.Tests.csproj +└── examples/BasicUsage/ ← runnable console example + ├── Program.cs + └── BasicUsage.csproj +``` + +--- + +## License + +Released under the [MIT License](LICENSE) — © 2026 NEVSTOP-LAB. diff --git a/SDK/csharp/README.zh-cn.md b/SDK/csharp/README.zh-cn.md new file mode 100644 index 0000000..ca7b6be --- /dev/null +++ b/SDK/csharp/README.zh-cn.md @@ -0,0 +1,189 @@ +# CsmTcpRouter.Client + +[![NuGet](https://img.shields.io/nuget/v/CsmTcpRouter.Client.svg)](https://www.nuget.org/packages/CsmTcpRouter.Client/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![CI](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/CSharp_SDK.yml/badge.svg)](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/CSharp_SDK.yml) + +[CSM-TCP-Router](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App) LabVIEW 服务器的 C# / .NET 客户端 SDK。 + +CSM-TCP-Router 通过 TCP 暴露一个 LabVIEW [可通信状态机 (CSM)](https://github.com/NEVSTOP-LAB/Communicable-State-Machine) 应用程序,因此任何 TCP 客户端 — 包括 .NET 应用程序、测试夹具或 CI 流水线 — 都可以发送命令并接收响应,而无需修改 LabVIEW 代码。 + +> 📖 [English README.md](README.md) + +整个 SDK 实现位于单个源文件中:[`src/CsmTcpRouter/CsmTcpRouter.cs`](src/CsmTcpRouter/CsmTcpRouter.cs)。 + +--- + +## 安装 + +```bash +dotnet add package CsmTcpRouter.Client +``` + +目标框架:`netstandard2.0` 与 `net8.0`。无第三方运行时依赖 — 仅使用 .NET BCL。 + +支持的平台包括 .NET Framework 4.6.2+、.NET Core 3.1+ 以及 .NET 5/6/7/8/9。 + +--- + +## 快速上手 + +### 同步客户端 + +```csharp +using CsmTcpRouter; + +using var client = new TcpRouterClient(); +client.Connect("localhost", 30007); + +// 列出已加载的所有 CSM 模块 +Console.WriteLine(client.ListModules()); + +// 发送同步命令并等待响应 +var resp = client.SendAndWait("API: Read -@ DAQmx"); +Console.WriteLine(resp.Text); + +// Ping 服务器 +var (ok, elapsed) = client.Ping(); +Console.WriteLine($"Ping: {ok}, latency={elapsed.TotalMilliseconds:F1} ms"); +``` + +### 异步客户端 + +```csharp +using CsmTcpRouter; + +using var client = new TcpRouterClient(); +await client.ConnectAsync("localhost", 30007); + +Console.WriteLine(await client.ListModulesAsync()); +var resp = await client.SendAndWaitAsync("API: Read -@ DAQmx"); +Console.WriteLine(resp.Text); +``` + +### 订阅状态广播 + +```csharp +client.SubscribeStatus("Status", "AI", notif => +{ + Console.WriteLine($"{notif.StatusName} = {notif.Data}"); +}); + +// ... 之后 +client.UnsubscribeStatus("Status", "AI"); +``` + +### 异步响应回调 + +```csharp +client.RegisterAsyncCallback("API: Start Sampling -> DAQmx", ar => +{ + Console.WriteLine($"异步响应: {ar.Text}"); +}); + +client.Post("API: Start Sampling -> DAQmx"); +``` + +完整可运行示例见 [`examples/BasicUsage/Program.cs`](examples/BasicUsage/Program.cs)。 + +--- + +## API 参考 + +### `TcpRouterClient` + +| 方法 | 说明 | +| ------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `Connect / ConnectAsync(host, port, timeout?)` | 建立 TCP 连接。 | +| `Disconnect()` | 关闭连接,并以 `RouterConnectionException` 立即唤醒所有阻塞中的等待者。 | +| `Connected` | 套接字打开期间为 `true`。 | +| `WaitForServer / WaitForServerAsync(...)` | 轮询 `host:port` 直到连接成功或超时。 | +| `SendAndWait / SendAndWaitAsync(cmd, timeout?)` | 发送同步命令 (`-@`) 并阻塞直到 `RESP` 返回。返回 `CommandResponse`。 | +| `Post / PostAsync(cmd, timeout?)` | 发送异步命令 (`->`) 并等待 `CMD_RESP` 握手。 | +| `PostNoReply / PostNoReplyAsync(cmd, timeout?)` | 发送无响应异步命令 (`->\|`) 并等待 `CMD_RESP` 握手。 | +| `Ping / PingAsync(timeout?)` | 测量往返延迟;返回 `(bool ok, TimeSpan elapsed)`。 | +| `ListModules / ListApi / ListStates / Help(...)` | 内置的路由管理辅助命令。 | +| `SubscribeStatus / UnsubscribeStatus(...)` | 订阅 / 取消 `STATUS` (或 `INTERRUPT`) 广播。 | +| `RegisterAsyncCallback / UnregisterAsyncCallback(...)` | 注册 / 移除按原始命令匹配的 `ASYNC_RESP` 回调。 | +| `AsyncResponseQueue`、`StatusQueue` | 与回调互补的轮询队列 (`ConcurrentQueue<>`)。 | + +### 数据模型 + +* `PacketType` (枚举) — 协议 v0 中定义的报文类型。 +* `Packet`、`CommandResponse`、`AsyncResponse`、`StatusNotification` — 解析后的负载。 + +### 异常 + +* `CsmTcpRouterException` — 基类。 +* `RouterConnectionException` — 连接 / 发送 / 断开失败。 +* `RouterTimeoutException` — 同步等待超时。 +* `ProtocolException` — 报文格式非法。 +* `ServerException` — 服务器返回 `ERROR` 报文 (`Code` 与 `ServerMessage`)。 + +--- + +## 协议 + +CSM-TCP-Router 协议 v0 使用 8 字节大端头部加上任意长度的负载: + +``` +| Data Length (4B) | Version (1B = 0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | ++------------------------ Header (8B) ----------------------------+ +``` + +报文类型: + +| 值 | 名称 | 方向 | 用途 | +| ----- | ------------ | ------------- | ------------------------------------------ | +| 0x00 | `Info` | 服务器 → 客户 | 欢迎 / 告别消息。 | +| 0x01 | `Error` | 服务器 → 客户 | 错误响应 (`[Error: ] `)。 | +| 0x02 | `Cmd` | 客户 → 服务器 | 客户端命令。 | +| 0x03 | `CmdResp` | 服务器 → 客户 | 异步 / 无响应 / 订阅命令的握手。 | +| 0x04 | `Resp` | 服务器 → 客户 | 同步命令响应。 | +| 0x05 | `AsyncResp` | 服务器 → 客户 | 异步命令响应 (回显原始命令)。 | +| 0x06 | `Status` | 服务器 → 客户 | 已订阅模块的状态广播。 | +| 0x07 | `Interrupt` | 服务器 → 客户 | 已订阅模块的中断广播。 | + +--- + +## 从源码构建 + +```bash +# 还原并构建整个解决方案 +dotnet build SDK/csharp/CsmTcpRouter.sln -c Release + +# 运行单元 + 集成测试 +dotnet test SDK/csharp/tests/CsmTcpRouter.Tests/CsmTcpRouter.Tests.csproj -c Release + +# 打包 NuGet +dotnet pack SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.csproj -c Release -o nupkg +``` + +解决方案文件可直接用 **Visual Studio 2022 / 2026**(或 **JetBrains Rider**)打开;SDK 风格项目向前兼容所有当前 Visual Studio 版本。 + +--- + +## 项目结构 + +``` +SDK/csharp/ +├── CsmTcpRouter.sln +├── README.md / README.zh-cn.md / CHANGELOG.md / LICENSE +├── src/CsmTcpRouter/ ← 单文件 SDK +│ ├── CsmTcpRouter.cs +│ └── CsmTcpRouter.csproj +├── tests/CsmTcpRouter.Tests/ ← xUnit 测试工程 +│ ├── ProtocolTests.cs +│ ├── ClientIntegrationTests.cs +│ ├── MockServer.cs +│ └── CsmTcpRouter.Tests.csproj +└── examples/BasicUsage/ ← 可运行控制台示例 + ├── Program.cs + └── BasicUsage.csproj +``` + +--- + +## 许可证 + +基于 [MIT 许可证](LICENSE) 发布 — © 2026 NEVSTOP-LAB。 diff --git a/SDK/csharp/examples/BasicUsage/BasicUsage.csproj b/SDK/csharp/examples/BasicUsage/BasicUsage.csproj new file mode 100644 index 0000000..5230e09 --- /dev/null +++ b/SDK/csharp/examples/BasicUsage/BasicUsage.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + latest + disable + CsmTcpRouter.Examples.BasicUsage + BasicUsage + false + + + + + + + diff --git a/SDK/csharp/examples/BasicUsage/Program.cs b/SDK/csharp/examples/BasicUsage/Program.cs new file mode 100644 index 0000000..2bdd24a --- /dev/null +++ b/SDK/csharp/examples/BasicUsage/Program.cs @@ -0,0 +1,91 @@ +using System; +using CsmTcpRouter; + +namespace CsmTcpRouter.Examples.BasicUsage +{ + /// + /// csm-tcp-router-client(C#)的基本用法示例。 + /// 镜像 SDK/python/examples/basic_usage.py。 + /// + /// 前提条件:正在运行的 CSM-TCP-Router 服务器(LabVIEW 应用程序)。 + /// 参考应用程序默认使用端口 30007。 + /// + public static class Program + { + private const string Host = "localhost"; + private const int Port = 30007; + + public static int Main(string[] args) + { + // 1. 等待服务器就绪。 + Console.Write("Waiting for server ... "); + using (var probe = new TcpRouterClient()) + { + bool ok = probe.WaitForServer(Host, Port, TimeSpan.FromSeconds(30), TimeSpan.FromMilliseconds(500)); + if (!ok) + { + Console.WriteLine("TIMEOUT - server did not start within 30 s."); + return 1; + } + } + Console.WriteLine("ready."); + + // 2. 连接(使用 IDisposable,确保始终调用 Disconnect)。 + using (var client = new TcpRouterClient()) + { + try + { + client.Connect(Host, Port, TimeSpan.FromSeconds(5)); + } + catch (RouterConnectionException exc) + { + Console.WriteLine($"Connection failed: {exc.Message}"); + return 1; + } + + Console.WriteLine($"Connected to {Host}:{Port}"); + + // 3. Ping 测试 + var (ok, elapsed) = client.Ping(TimeSpan.FromSeconds(2)); + Console.WriteLine(ok + ? $"Ping OK latency={elapsed.TotalMilliseconds:F1} ms" + : "Ping failed."); + + // 4. 列出 CSM 模块 + string modules = client.ListModules(); + Console.WriteLine($"\nLoaded modules:\n{modules}"); + + // 5. 列出第一个模块的 API(如有) + string firstModule = null; + foreach (var line in modules.Split('\n')) + { + var trimmed = line.Trim(); + if (!string.IsNullOrEmpty(trimmed)) + { + firstModule = trimmed; + break; + } + } + if (firstModule != null) + { + string api = client.ListApi(firstModule); + Console.WriteLine($"\nAPI for '{firstModule}':\n{api}"); + } + + // 6. 发送同步命令(取消注释并适配您的 CSM): + // var resp = client.SendAndWait("API: Read -@ DAQmx"); + // Console.WriteLine($"\nSync response: {resp.Text}"); + + // 7. 发送异步命令并等待 cmd-resp 握手: + // client.Post("API: Start Sampling -> DAQmx"); + + // 8. 发送无回复命令: + // client.PostNoReply("API: Reset ->| DAQmx"); + + Console.WriteLine("\nDone."); + } + Console.WriteLine("Disconnected."); + return 0; + } + } +} diff --git a/SDK/csharp/examples/ClientConsole/ClientConsole.csproj b/SDK/csharp/examples/ClientConsole/ClientConsole.csproj new file mode 100644 index 0000000..8d84430 --- /dev/null +++ b/SDK/csharp/examples/ClientConsole/ClientConsole.csproj @@ -0,0 +1,17 @@ + + + + Exe + net8.0 + latest + disable + CsmTcpRouter.Examples.ClientConsole + ClientConsole + false + + + + + + + diff --git a/SDK/csharp/examples/ClientConsole/Program.cs b/SDK/csharp/examples/ClientConsole/Program.cs new file mode 100644 index 0000000..29823ab --- /dev/null +++ b/SDK/csharp/examples/ClientConsole/Program.cs @@ -0,0 +1,236 @@ +using System; +using CsmTcpRouter; + +namespace CsmTcpRouter.Examples.ClientConsole +{ + /// + /// 交互式客户端控制台示例。连接到正在运行的 CSM-TCP-Router 服务器, + /// 接收用户从 stdin 输入的命令,并通过 SDK 转发。 + /// + /// 同样的命令集、提示符和输出格式也在 Python(examples/client_console.py) + /// 和 C(examples/client_console.c)SDK 示例中实现,因此三种语言的行为一致。 + /// + public static class Program + { + private const string DefaultHost = "localhost"; + private const int DefaultPort = 30007; + + private const string HelpText = + "Available commands:\n" + + " help Show this help text\n" + + " quit / exit Disconnect and exit\n" + + " ping Measure round-trip latency\n" + + " list List CSM modules loaded on the server\n" + + " api List the API of a module\n" + + " state List the states of a module\n" + + " mhelp Server-side Help for a module\n" + + " send Send a synchronous command and print the response\n" + + " post Send an asynchronous command (-> suffix)\n" + + " nopost Send a no-reply asynchronous command (->|)\n" + + " sub @ Subscribe to a status broadcast\n" + + " unsub @ Unsubscribe from a status broadcast"; + + public static int Main(string[] args) + { + string host = args.Length > 0 ? args[0] : DefaultHost; + int port = DefaultPort; + if (args.Length > 1) + { + if (!int.TryParse(args[1], out port) || port < 1 || port > 65535) + { + Console.WriteLine($"Error: invalid port '{args[1]}'"); + return 1; + } + } + + Console.WriteLine("CSM-TCP-Router Client Console"); + Console.WriteLine($"Connecting to {host}:{port} ..."); + + using var client = new TcpRouterClient(); + try + { + client.Connect(host, port); + } + catch (RouterConnectionException exc) + { + Console.WriteLine($"Error: {exc.Message}"); + return 1; + } + + Console.WriteLine($"Connected to {host}:{port}. Type 'help' for commands, 'quit' to exit."); + + while (true) + { + Console.Write("csm> "); + string line = Console.ReadLine(); + if (line == null) + { + Console.WriteLine(); + break; + } + + bool keepRunning; + try + { + keepRunning = Dispatch(client, line); + } + catch (CsmTcpRouterException exc) + { + Console.WriteLine($"Error: {exc.Message}"); + continue; + } + catch (ArgumentException exc) + { + Console.WriteLine($"Error: {exc.Message}"); + continue; + } + + if (!keepRunning) + { + break; + } + } + + client.Disconnect(); + Console.WriteLine("Disconnected."); + return 0; + } + + private static bool Dispatch(TcpRouterClient client, string line) + { + line = line.Trim(); + if (line.Length == 0) + { + return true; + } + + int spaceIndex = line.IndexOf(' '); + string cmd = (spaceIndex < 0 ? line : line.Substring(0, spaceIndex)).ToLowerInvariant(); + string arg = spaceIndex < 0 ? string.Empty : line.Substring(spaceIndex + 1).Trim(); + + switch (cmd) + { + case "quit": + case "exit": + return false; + + case "help": + Console.WriteLine(HelpText); + return true; + + case "ping": + { + var (ok, elapsed) = client.Ping(); + Console.WriteLine(ok + ? $"Ping OK latency={elapsed.TotalMilliseconds:F1} ms" + : "Ping failed."); + return true; + } + + case "list": + Console.WriteLine(client.ListModules()); + return true; + + case "api": + if (arg.Length == 0) Console.WriteLine("Error: usage: api "); + else Console.WriteLine(client.ListApi(arg)); + return true; + + case "state": + if (arg.Length == 0) Console.WriteLine("Error: usage: state "); + else Console.WriteLine(client.ListStates(arg)); + return true; + + case "mhelp": + if (arg.Length == 0) Console.WriteLine("Error: usage: mhelp "); + else Console.WriteLine(client.Help(arg)); + return true; + + case "send": + if (arg.Length == 0) + { + Console.WriteLine("Error: usage: send "); + } + else + { + var resp = client.SendAndWait(arg); + Console.WriteLine($"Response: {resp.Text}"); + } + return true; + + case "post": + if (arg.Length == 0) + { + Console.WriteLine("Error: usage: post "); + } + else + { + client.RegisterAsyncCallback(arg, OnAsync); + client.Post(arg); + Console.WriteLine("Async command sent."); + } + return true; + + case "nopost": + if (arg.Length == 0) + { + Console.WriteLine("Error: usage: nopost "); + } + else + { + client.PostNoReply(arg); + Console.WriteLine("No-reply command sent."); + } + return true; + + case "sub": + { + var (status, module) = SplitStatusModule(arg); + client.SubscribeStatus(status, module, OnStatus); + Console.WriteLine($"Subscribed to {status}@{module}"); + return true; + } + + case "unsub": + { + var (status, module) = SplitStatusModule(arg); + client.UnsubscribeStatus(status, module); + Console.WriteLine($"Unsubscribed from {status}@{module}"); + return true; + } + + default: + Console.WriteLine($"Error: unknown command '{cmd}'. Type 'help' for the command list."); + return true; + } + } + + private static (string Status, string Module) SplitStatusModule(string arg) + { + int at = arg.IndexOf('@'); + if (at < 0) + { + throw new ArgumentException("expected '@'"); + } + string status = arg.Substring(0, at).Trim(); + string module = arg.Substring(at + 1).Trim(); + if (status.Length == 0 || module.Length == 0) + { + throw new ArgumentException("expected '@'"); + } + return (status, module); + } + + private static void OnStatus(StatusNotification notification) + { + Console.WriteLine(); + Console.WriteLine($"[STATUS] {notification.StatusName}@{notification.ModuleName}: {notification.Data}"); + } + + private static void OnAsync(AsyncResponse response) + { + Console.WriteLine(); + Console.WriteLine($"[ASYNC] {response.Text} (cmd={response.OriginalCommand})"); + } + } +} diff --git a/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs b/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs new file mode 100644 index 0000000..6d1fa91 --- /dev/null +++ b/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs @@ -0,0 +1,920 @@ +// CsmTcpRouter.cs +// --------------------------------------------------------------------------- +// csm-tcp-router-client - CSM-TCP-Router LabVIEW 服务器的 C# 客户端 SDK。 +// +// 单文件 SDK,实现 CSM-TCP-Router 协议 v0。镜像了 +// Python `csm_tcp_router` 包的布局和功能: +// +// * 协议编解码器(8 字节头,大端序,8 种数据包类型)。 +// * 后台接收 TCP 传输层。 +// * 高层 TcpRouterClient,提供同步和异步 API: +// - SendAndWait / SendAndWaitAsync (同步 CMD/RESP) +// - Post / PostAsync (带 cmd-resp 握手的异步 CMD) +// - PostNoReply / PostNoReplyAsync (无回复异步 CMD) +// - Ping / PingAsync (往返延迟) +// - ListModules / ListApi / ListStates / Help +// - SubscribeStatus / UnsubscribeStatus +// - RegisterAsyncCallback / UnregisterAsyncCallback +// +// 线路格式(8 字节头,大端序): +// +// | Data Length (4B) | Version (1B=0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | +// +------------------------ Header (8B) -----------------------+ +// +// 后跟恰好 `Data Length` 字节的有效载荷。 +// +// Copyright (c) 2026 NEVSTOP-LAB. Released under the MIT License. +// --------------------------------------------------------------------------- + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +[assembly: InternalsVisibleTo("CsmTcpRouter.Tests")] + +namespace CsmTcpRouter +{ + // ----------------------------------------------------------------------- + // 公共枚举 + // ----------------------------------------------------------------------- + + /// + /// CSM-TCP-Router 协议 v0 中定义的数据包类型常量。 + /// + public enum PacketType : byte + { + /// 信息消息(欢迎/再见)。 + Info = 0x00, + /// 来自服务器的错误数据包。 + Error = 0x01, + /// 客户端发送的命令。 + Cmd = 0x02, + /// 服务器对异步/无回复/订阅命令的握手确认。 + CmdResp = 0x03, + /// 同步响应有效载荷。 + Resp = 0x04, + /// 异步响应有效载荷。 + AsyncResp = 0x05, + /// 来自已订阅 CSM 模块的状态广播。 + Status = 0x06, + /// 来自已订阅 CSM 模块的中断广播。 + Interrupt = 0x07, + } + + // ----------------------------------------------------------------------- + // 公共数据模型 + // ----------------------------------------------------------------------- + + /// 从服务器接收到的已解码数据包。 + public sealed class Packet + { + public PacketType Type { get; } + public byte[] Data { get; } + public byte Version { get; } + public byte Flag1 { get; } + public byte Flag2 { get; } + + public Packet(PacketType type, byte[] data, byte version = 1, byte flag1 = 0, byte flag2 = 0) + { + Type = type; + Data = data ?? Array.Empty(); + Version = version; + Flag1 = flag1; + Flag2 = flag2; + } + } + + /// 同步命令()的结果。 + public sealed class CommandResponse + { + public byte[] Raw { get; } + public string Text => Encoding.UTF8.GetString(Raw); + + public CommandResponse(byte[] raw) + { + Raw = raw ?? Array.Empty(); + } + + public override string ToString() => $"CommandResponse(\"{Text}\")"; + } + + /// 通过异步响应数据包传递的异步响应有效载荷。 + public sealed class AsyncResponse + { + public byte[] Raw { get; } + public string OriginalCommand { get; } + public string Text => Encoding.UTF8.GetString(Raw); + + public AsyncResponse(byte[] raw, string originalCommand = "") + { + Raw = raw ?? Array.Empty(); + OriginalCommand = originalCommand ?? string.Empty; + } + + /// + /// 解析 ASYNC_RESP 数据包。服务器格式: + /// "<response-data> <- <original-command>"。 + /// + public static AsyncResponse FromPacket(Packet packet) + { + if (packet == null) throw new ArgumentNullException(nameof(packet)); + string text = Encoding.UTF8.GetString(packet.Data); + int sep = text.IndexOf(" <- ", StringComparison.Ordinal); + if (sep >= 0) + { + string left = text.Substring(0, sep); + string right = text.Substring(sep + 4); + return new AsyncResponse(Encoding.UTF8.GetBytes(left), right); + } + return new AsyncResponse(packet.Data); + } + + public override string ToString() => $"AsyncResponse(\"{Text}\", cmd=\"{OriginalCommand}\")"; + } + + /// 通过 STATUS 或 INTERRUPT 数据包传递的状态广播。 + public sealed class StatusNotification + { + public byte[] Raw { get; } + public PacketType PacketType { get; } + public string StatusName { get; } + public string Data { get; } + public string ModuleName { get; } + + public StatusNotification( + byte[] raw, + PacketType packetType = PacketType.Status, + string statusName = "", + string data = "", + string moduleName = "") + { + Raw = raw ?? Array.Empty(); + PacketType = packetType; + StatusName = statusName ?? string.Empty; + Data = data ?? string.Empty; + ModuleName = moduleName ?? string.Empty; + } + + /// + /// 解析 STATUS 或 INTERRUPT 数据包。服务器格式: + /// "<status-name> >> <data> <- <module>"。 + /// + public static StatusNotification FromPacket(Packet packet) + { + if (packet == null) throw new ArgumentNullException(nameof(packet)); + string text = Encoding.UTF8.GetString(packet.Data); + string module = string.Empty; + string left = text; + int sepArrow = text.LastIndexOf(" <- ", StringComparison.Ordinal); + if (sepArrow >= 0) + { + left = text.Substring(0, sepArrow); + module = text.Substring(sepArrow + 4).Trim(); + } + string statusName = string.Empty; + string data = left.Trim(); + int sepGtGt = left.IndexOf(" >> ", StringComparison.Ordinal); + if (sepGtGt >= 0) + { + statusName = left.Substring(0, sepGtGt).Trim(); + data = left.Substring(sepGtGt + 4).Trim(); + } + return new StatusNotification(packet.Data, packet.Type, statusName, data, module); + } + + public override string ToString() => + $"StatusNotification(status=\"{StatusName}\", data=\"{Data}\", module=\"{ModuleName}\")"; + } + + // ----------------------------------------------------------------------- + // 异常层次结构 + // ----------------------------------------------------------------------- + + /// 所有 CSM-TCP-Router 客户端错误的基础异常。 + public class CsmTcpRouterException : Exception + { + public CsmTcpRouterException() { } + public CsmTcpRouterException(string message) : base(message) { } + public CsmTcpRouterException(string message, Exception innerException) : base(message, innerException) { } + } + + /// 当连接无法建立或连接丢失时引发。 + public class RouterConnectionException : CsmTcpRouterException + { + public RouterConnectionException(string message) : base(message) { } + public RouterConnectionException(string message, Exception innerException) : base(message, innerException) { } + } + + /// 当同步操作超过其超时时间时引发。 + public class RouterTimeoutException : CsmTcpRouterException + { + public RouterTimeoutException(string message) : base(message) { } + } + + /// 当收到无效或意外的协议帧时引发。 + public class ProtocolException : CsmTcpRouterException + { + public ProtocolException(string message) : base(message) { } + } + + /// + /// 当服务器返回错误数据包时引发。CSM 错误格式: + /// [Error: <code>] <message>。 + /// + public class ServerException : CsmTcpRouterException + { + public string Code { get; } + public string ServerMessage { get; } + + public ServerException(string message, string code = "") + : base(message) + { + ServerMessage = message ?? string.Empty; + Code = code ?? string.Empty; + } + + public override string ToString() + { + return string.IsNullOrEmpty(Code) + ? ServerMessage + : $"[Error: {Code}] {ServerMessage}"; + } + } + + // ----------------------------------------------------------------------- + // 内部协议编解码器 + // ----------------------------------------------------------------------- + + internal static class ProtocolCodec + { + public const int HeaderSize = 8; + public const byte ProtocolVersion = 0x01; + + /// 编码为完整的线路格式数据包(头 + 体)。 + public static byte[] EncodePacket(byte[] data, PacketType packetType, byte flag1 = 0, byte flag2 = 0) + { + data = data ?? Array.Empty(); + var wire = new byte[HeaderSize + data.Length]; + uint len = (uint)data.Length; + wire[0] = (byte)((len >> 24) & 0xFF); + wire[1] = (byte)((len >> 16) & 0xFF); + wire[2] = (byte)((len >> 8) & 0xFF); + wire[3] = (byte)(len & 0xFF); + wire[4] = ProtocolVersion; + wire[5] = (byte)packetType; + wire[6] = flag1; + wire[7] = flag2; + Buffer.BlockCopy(data, 0, wire, HeaderSize, data.Length); + return wire; + } + + /// 将 8 字节头解码为其组成字段。 + public static (uint DataLen, byte Version, byte TypeByte, byte Flag1, byte Flag2) DecodeHeader(byte[] header) + { + if (header == null || header.Length != HeaderSize) + throw new ProtocolException( + $"Expected {HeaderSize}-byte header, got {(header == null ? 0 : header.Length)} bytes."); + uint dataLen = ((uint)header[0] << 24) | ((uint)header[1] << 16) | ((uint)header[2] << 8) | header[3]; + return (dataLen, header[4], header[5], header[6], header[7]); + } + + /// 从原始头 + 体构建 + public static Packet ParsePacket(byte[] header, byte[] body) + { + var (dataLen, version, typeByte, flag1, flag2) = DecodeHeader(header); + body = body ?? Array.Empty(); + if ((uint)body.Length != dataLen) + throw new ProtocolException( + $"Payload length mismatch: header says {dataLen} bytes, got {body.Length} bytes."); + // 向前兼容:未知类型字节映射为 Info。 + PacketType ptype = Enum.IsDefined(typeof(PacketType), typeByte) + ? (PacketType)typeByte + : PacketType.Info; + return new Packet(ptype, body, version, flag1, flag2); + } + + /// 从 CSM 错误格式 [Error: code] msg 中提取代码和消息。 + public static ServerException ParseServerError(Packet packet) + { + string text = Encoding.UTF8.GetString(packet.Data).Trim(); + string code = string.Empty; + string msg = text; + if (text.StartsWith("[Error:", StringComparison.Ordinal)) + { + int end = text.IndexOf(']'); + if (end > 0) + { + code = text.Substring(7, end - 7).Trim(); + msg = text.Substring(end + 1).Trim(); + } + } + return new ServerException(msg, code); + } + } + + // ----------------------------------------------------------------------- + // 内部 TCP 传输层(后台接收任务) + // ----------------------------------------------------------------------- + + internal sealed class Transport : IDisposable + { + private readonly object _sendLock = new object(); + private readonly Action _onPacket; + private readonly Action _onDisconnect; + + private TcpClient _client; + private NetworkStream _stream; + private CancellationTokenSource _cts; + private Task _recvTask; + private volatile bool _stopped; + + public Transport(Action onPacket, Action onDisconnect) + { + _onPacket = onPacket ?? throw new ArgumentNullException(nameof(onPacket)); + _onDisconnect = onDisconnect ?? throw new ArgumentNullException(nameof(onDisconnect)); + } + + public bool Connected + { + get + { + var c = _client; + return c != null && c.Connected && !_stopped; + } + } + + public void Connect(string host, int port, TimeSpan? timeout = null) + { + ConnectAsync(host, port, timeout).GetAwaiter().GetResult(); + } + + public async Task ConnectAsync(string host, int port, TimeSpan? timeout = null) + { + if (Connected) + throw new RouterConnectionException("Already connected; call Disconnect() first."); + var to = timeout ?? TimeSpan.FromSeconds(5); + var client = new TcpClient(); + try + { + var connectTask = client.ConnectAsync(host, port); + var winner = await Task.WhenAny(connectTask, Task.Delay(to)).ConfigureAwait(false); + if (winner != connectTask) + { + try { client.Close(); } catch { /* 忽略 */ } + throw new RouterConnectionException( + $"Cannot connect to {host}:{port}: timed out after {to.TotalSeconds:F1}s."); + } + await connectTask.ConfigureAwait(false); // 让任何连接异常浮现 + } + catch (RouterConnectionException) + { + throw; + } + catch (Exception exc) + { + try { client.Close(); } catch { /* 忽略 */ } + throw new RouterConnectionException($"Cannot connect to {host}:{port}: {exc.Message}", exc); + } + + _client = client; + _stream = client.GetStream(); + _stopped = false; + _cts = new CancellationTokenSource(); + _recvTask = Task.Run(() => RecvLoopAsync(_cts.Token)); + } + + public void Disconnect(TimeSpan? joinTimeout = null) + { + _stopped = true; + try { _cts?.Cancel(); } catch { /* 忽略 */ } + try { _stream?.Close(); } catch { /* 忽略 */ } + try { _client?.Close(); } catch { /* 忽略 */ } + _stream = null; + _client = null; + var jt = joinTimeout ?? TimeSpan.FromSeconds(2); + try { _recvTask?.Wait(jt); } catch { /* 忽略 */ } + _recvTask = null; + try { _cts?.Dispose(); } catch { /* 忽略 */ } + _cts = null; + } + + public void SendRaw(byte[] data) + { + if (!Connected) throw new RouterConnectionException("Not connected."); + lock (_sendLock) + { + try + { + var s = _stream; + if (s == null) throw new RouterConnectionException("Not connected."); + s.Write(data, 0, data.Length); + } + catch (Exception exc) when (!(exc is RouterConnectionException)) + { + _stopped = true; + throw new RouterConnectionException($"Send failed: {exc.Message}", exc); + } + } + } + + public void Dispose() => Disconnect(); + + // --------------------------------------------------------------- + // 内部:后台接收循环 + // --------------------------------------------------------------- + + private async Task RecvLoopAsync(CancellationToken ct) + { + var stream = _stream; + try + { + var headerBuf = new byte[ProtocolCodec.HeaderSize]; + while (!ct.IsCancellationRequested) + { + if (!await ReadExactlyAsync(stream, headerBuf, 0, headerBuf.Length, ct).ConfigureAwait(false)) + break; + + uint dataLen = ((uint)headerBuf[0] << 24) | ((uint)headerBuf[1] << 16) | ((uint)headerBuf[2] << 8) | headerBuf[3]; + byte[] body = dataLen == 0 ? Array.Empty() : new byte[dataLen]; + if (dataLen > 0 && !await ReadExactlyAsync(stream, body, 0, body.Length, ct).ConfigureAwait(false)) + break; + + Packet packet; + try + { + packet = ProtocolCodec.ParsePacket(headerBuf, body); + } + catch (ProtocolException) + { + // 损坏的帧——跳过并保持循环运行。 + continue; + } + + try { _onPacket(packet); } catch { /* swallow callback errors */ } + } + } + catch (IOException) { /* 连接已断开 */ } + catch (ObjectDisposedException) { /* 读取期间套接字已关闭 */ } + catch (OperationCanceledException) { /* 正在关闭 */ } + finally + { + if (!_stopped) + { + _stopped = true; + try { _onDisconnect(); } catch { /* 忽略 */ } + } + } + } + + private static async Task ReadExactlyAsync(Stream stream, byte[] buf, int offset, int count, CancellationToken ct) + { + int read = 0; + while (read < count) + { + int n; + try + { + n = await stream.ReadAsync(buf, offset + read, count - read, ct).ConfigureAwait(false); + } + catch (IOException) { return false; } + catch (ObjectDisposedException) { return false; } + if (n == 0) return false; + read += n; + } + return true; + } + } + + // ----------------------------------------------------------------------- + // 高层客户端 + // ----------------------------------------------------------------------- + + /// 用于状态/中断广播的回调委托。 + public delegate void StatusCallback(StatusNotification notification); + + /// 用于异步响应数据包的回调委托。 + public delegate void AsyncResponseCallback(AsyncResponse response); + + /// + /// CSM-TCP-Router 服务器的 C# 客户端。镜像了 LabVIEW ClientAPI VI + /// 和 Python TcpRouterClient;使用协议 v0。 + /// + /// 该类是线程安全的。任意时刻最多只能有一个正在执行的同步命令 + /// 和一个正在执行的异步/订阅命令;并发调用者由内部信号量序列化。 + /// + public sealed class TcpRouterClient : IDisposable + { + // 通过 TCS 实现的单元素"队列",用于同步等待。 + // 每个等待者在相应的锁内将其重置为新的 TCS。 + private TaskCompletionSource _respTcs; + private TaskCompletionSource _cmdRespTcs; + + private readonly SemaphoreSlim _respLock = new SemaphoreSlim(1, 1); + private readonly SemaphoreSlim _cmdRespLock = new SemaphoreSlim(1, 1); + + private readonly object _stateLock = new object(); + private readonly Dictionary<(string Status, string Module), StatusCallback> _statusCallbacks + = new Dictionary<(string, string), StatusCallback>(); + private readonly Dictionary _asyncCallbacks + = new Dictionary(); + + private readonly Transport _transport; + + /// 用于轮询从服务器接收到的异步响应数据包的队列。 + public ConcurrentQueue AsyncResponseQueue { get; } = new ConcurrentQueue(); + + /// 用于轮询状态/中断通知的队列。 + public ConcurrentQueue StatusQueue { get; } = new ConcurrentQueue(); + + public TcpRouterClient() + { + _transport = new Transport(OnPacket, OnDisconnect); + } + + // --------------------------------------------------------------- + // 连接管理 + // --------------------------------------------------------------- + + /// 连接到 CSM-TCP-Router 服务器。 + public void Connect(string host, int port, TimeSpan? timeout = null) + => _transport.Connect(host, port, timeout); + + /// 连接到 CSM-TCP-Router 服务器(异步)。 + public Task ConnectAsync(string host, int port, TimeSpan? timeout = null) + => _transport.ConnectAsync(host, port, timeout); + + /// + /// 从服务器断开连接并释放所有资源。当前阻塞在 + /// / 中的线程将立即 + /// 收到 ,而不是等待超时。 + /// + public void Disconnect() + { + // 在拆除传输层之前解除所有挂起等待者的阻塞。 + var sentinel = new RouterConnectionException("Disconnected from server."); + UnblockWaiters(sentinel); + _transport.Disconnect(); + } + + /// 当底层传输层已连接时为 true + public bool Connected => _transport.Connected; + + /// + /// 轮询直到 : 接受连接 + /// 或 超时。 + /// + public bool WaitForServer(string host, int port, TimeSpan? timeout = null, TimeSpan? retryInterval = null) + => WaitForServerAsync(host, port, timeout, retryInterval).GetAwaiter().GetResult(); + + /// 的异步版本。 + public async Task WaitForServerAsync( + string host, int port, TimeSpan? timeout = null, TimeSpan? retryInterval = null) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(30)); + var interval = retryInterval ?? TimeSpan.FromMilliseconds(500); + while (DateTime.UtcNow < deadline) + { + using (var probe = new TcpClient()) + { + var connectTask = probe.ConnectAsync(host, port); + var winner = await Task.WhenAny(connectTask, Task.Delay(TimeSpan.FromSeconds(1))).ConfigureAwait(false); + if (winner == connectTask) + { + try + { + // 观察任何连接异常(已故障任务); + // 成功表示服务器可访问。 + await connectTask.ConfigureAwait(false); + try { probe.Close(); } catch { /* 忽略 */ } + return true; + } + catch (SocketException) { /* 尚未就绪 */ } + catch (IOException) { /* 尚未就绪 */ } + } + else + { + // 延迟获胜;通过关闭探测套接字终止正在进行的连接尝试, + // 然后观察任何挂起的异常,以免其未被观察到。 + try { probe.Close(); } catch { /* 忽略 */ } + try + { + await connectTask.ConfigureAwait(false); + } + catch (SocketException) { /* 尚未就绪 */ } + catch (IOException) { /* 尚未就绪 */ } + catch (ObjectDisposedException) { /* 关闭探测套接字导致连接中止 */ } + } + } + await Task.Delay(interval).ConfigureAwait(false); + } + return false; + } + + // --------------------------------------------------------------- + // 核心命令方法(同步包装) + // --------------------------------------------------------------- + + public CommandResponse SendAndWait(string command, TimeSpan? timeout = null) + => SendAndWaitAsync(command, timeout).GetAwaiter().GetResult(); + + public void Post(string command, TimeSpan? timeout = null) + => PostAsync(command, timeout).GetAwaiter().GetResult(); + + public void PostNoReply(string command, TimeSpan? timeout = null) + => PostNoReplyAsync(command, timeout).GetAwaiter().GetResult(); + + public (bool Ok, TimeSpan Elapsed) Ping(TimeSpan? timeout = null) + => PingAsync(timeout).GetAwaiter().GetResult(); + + public string ListModules(TimeSpan? timeout = null) => SendAndWait("List", timeout).Text; + public string ListApi(string module, TimeSpan? timeout = null) => SendAndWait($"List API {module}", timeout).Text; + public string ListStates(string module, TimeSpan? timeout = null) => SendAndWait($"List State {module}", timeout).Text; + public string Help(string module, TimeSpan? timeout = null) => SendAndWait($"Help {module}", timeout).Text; + + public void SubscribeStatus(string statusName, string moduleName, StatusCallback callback = null, TimeSpan? timeout = null) + => SubscribeStatusAsync(statusName, moduleName, callback, timeout).GetAwaiter().GetResult(); + + public void UnsubscribeStatus(string statusName, string moduleName, TimeSpan? timeout = null) + => UnsubscribeStatusAsync(statusName, moduleName, timeout).GetAwaiter().GetResult(); + + // --------------------------------------------------------------- + // 核心命令方法(异步) + // --------------------------------------------------------------- + + /// + /// 发送同步命令(后缀 -@)并等待响应。 + /// + public async Task SendAndWaitAsync(string command, TimeSpan? timeout = null) + { + if (command == null) throw new ArgumentNullException(nameof(command)); + var to = timeout ?? TimeSpan.FromSeconds(5); + byte[] wire = ProtocolCodec.EncodePacket(Encoding.UTF8.GetBytes(command), PacketType.Cmd); + + await _respLock.WaitAsync().ConfigureAwait(false); + try + { + _respTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _transport.SendRaw(wire); + return await WaitForRespAsync(to).ConfigureAwait(false); + } + finally + { + _respLock.Release(); + } + } + + /// + /// 发送异步命令(后缀 ->)并等待 cmd-resp 握手。 + /// + public Task PostAsync(string command, TimeSpan? timeout = null) + => SendAndAwaitCmdRespAsync(command, timeout); + + /// + /// 发送异步无回复命令(后缀 ->|)并等待 cmd-resp 握手。 + /// + public Task PostNoReplyAsync(string command, TimeSpan? timeout = null) + => SendAndAwaitCmdRespAsync(command, timeout); + + /// 发送 Ping 并测量往返延迟。 + public async Task<(bool Ok, TimeSpan Elapsed)> PingAsync(TimeSpan? timeout = null) + { + var to = timeout ?? TimeSpan.FromSeconds(2); + try + { + var sw = Stopwatch.StartNew(); + await SendAndWaitAsync("Ping", to).ConfigureAwait(false); + sw.Stop(); + return (true, sw.Elapsed); + } + catch (RouterConnectionException) { return (false, TimeSpan.Zero); } + catch (RouterTimeoutException) { return (false, TimeSpan.Zero); } + catch (ServerException) { return (false, TimeSpan.Zero); } + } + + public Task ListModulesAsync(TimeSpan? timeout = null) + => SendAndWaitAsync("List", timeout).ContinueWithText(); + + public Task ListApiAsync(string module, TimeSpan? timeout = null) + => SendAndWaitAsync($"List API {module}", timeout).ContinueWithText(); + + public Task ListStatesAsync(string module, TimeSpan? timeout = null) + => SendAndWaitAsync($"List State {module}", timeout).ContinueWithText(); + + public Task HelpAsync(string module, TimeSpan? timeout = null) + => SendAndWaitAsync($"Help {module}", timeout).ContinueWithText(); + + /// 订阅 CSM 模块的状态广播。 + public async Task SubscribeStatusAsync( + string statusName, string moduleName, StatusCallback callback = null, TimeSpan? timeout = null) + { + if (statusName == null) throw new ArgumentNullException(nameof(statusName)); + if (moduleName == null) throw new ArgumentNullException(nameof(moduleName)); + + var key = (statusName, moduleName); + // 在发送之前注册回调,以消除状态数据包在回调 + // 存储之前到达的竞争条件。 + lock (_stateLock) { _statusCallbacks[key] = callback; } + + string cmd = $"{statusName}@{moduleName} ->"; + try + { + await SendAndAwaitCmdRespAsync(cmd, timeout).ConfigureAwait(false); + } + catch + { + lock (_stateLock) { _statusCallbacks.Remove(key); } + throw; + } + } + + /// 取消状态订阅。 + public async Task UnsubscribeStatusAsync(string statusName, string moduleName, TimeSpan? timeout = null) + { + if (statusName == null) throw new ArgumentNullException(nameof(statusName)); + if (moduleName == null) throw new ArgumentNullException(nameof(moduleName)); + + string cmd = $"{statusName}@{moduleName} ->"; + await SendAndAwaitCmdRespAsync(cmd, timeout).ConfigureAwait(false); + lock (_stateLock) { _statusCallbacks.Remove((statusName, moduleName)); } + } + + /// + /// 为异步响应数据包注册回调,通过异步响应有效载荷中回显的原始命令 + /// (在 <- 分隔符之后)进行匹配。 + /// + public void RegisterAsyncCallback(string originalCommand, AsyncResponseCallback callback) + { + if (originalCommand == null) throw new ArgumentNullException(nameof(originalCommand)); + if (callback == null) throw new ArgumentNullException(nameof(callback)); + lock (_stateLock) { _asyncCallbacks[originalCommand] = callback; } + } + + /// 移除之前注册的异步回调。 + public void UnregisterAsyncCallback(string originalCommand) + { + if (originalCommand == null) return; + lock (_stateLock) { _asyncCallbacks.Remove(originalCommand); } + } + + // --------------------------------------------------------------- + // IDisposable + // --------------------------------------------------------------- + + public void Dispose() + { + Disconnect(); + _respLock.Dispose(); + _cmdRespLock.Dispose(); + } + + // --------------------------------------------------------------- + // 内部辅助方法 + // --------------------------------------------------------------- + + private async Task SendAndAwaitCmdRespAsync(string command, TimeSpan? timeout) + { + if (command == null) throw new ArgumentNullException(nameof(command)); + var to = timeout ?? TimeSpan.FromSeconds(5); + byte[] wire = ProtocolCodec.EncodePacket(Encoding.UTF8.GetBytes(command), PacketType.Cmd); + + await _cmdRespLock.WaitAsync().ConfigureAwait(false); + try + { + _cmdRespTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _transport.SendRaw(wire); + await WaitForCmdRespAsync(to).ConfigureAwait(false); + } + finally + { + _cmdRespLock.Release(); + } + } + + private async Task WaitForRespAsync(TimeSpan timeout) + { + var tcs = _respTcs; + var winner = await Task.WhenAny(tcs.Task, Task.Delay(timeout)).ConfigureAwait(false); + if (winner != tcs.Task) + { + // 协议 v0 没有关联 ID,因此超时命令的延迟 RESP 可能被 + // 错误地归属于*下一个* SendAndWait 调用。强制断开连接, + // 使连接在调用者重新连接之前不可用。 + try { _transport.Disconnect(); } catch { /* 忽略 */ } + throw new RouterTimeoutException($"No response received within {timeout.TotalSeconds:F1}s."); + } + object item = await tcs.Task.ConfigureAwait(false); + if (item is Exception exc) throw exc; + var packet = (Packet)item; + return new CommandResponse(packet.Data); + } + + private async Task WaitForCmdRespAsync(TimeSpan timeout) + { + var tcs = _cmdRespTcs; + var winner = await Task.WhenAny(tcs.Task, Task.Delay(timeout)).ConfigureAwait(false); + if (winner != tcs.Task) + { + // 与 WaitForRespAsync 中相同的去同步风险:延迟的 CMD_RESP 可能 + // 完成下一个正在等待的调用。强制断开连接,使握手超时后 + // 连接无法被复用。 + try { _transport.Disconnect(); } catch { /* 忽略 */ } + throw new RouterTimeoutException($"No cmd-resp received within {timeout.TotalSeconds:F1}s."); + } + object item = await tcs.Task.ConfigureAwait(false); + if (item is Exception exc) throw exc; + // CMD_RESP 有效载荷是握手确认;丢弃它。 + } + + private void UnblockWaiters(Exception sentinel) + { + _respTcs?.TrySetResult(sentinel); + _cmdRespTcs?.TrySetResult(sentinel); + } + + // --------------------------------------------------------------- + // 内部:数据包分发(在接收任务线程上运行) + // --------------------------------------------------------------- + + internal void OnPacket(Packet packet) + { + switch (packet.Type) + { + case PacketType.Resp: + _respTcs?.TrySetResult(packet); + break; + + case PacketType.CmdResp: + _cmdRespTcs?.TrySetResult(packet); + break; + + case PacketType.AsyncResp: + { + var resp = AsyncResponse.FromPacket(packet); + AsyncResponseQueue.Enqueue(resp); + AsyncResponseCallback cb; + lock (_stateLock) { _asyncCallbacks.TryGetValue(resp.OriginalCommand, out cb); } + if (cb != null) + { + try { cb(resp); } catch { /* 吞掉回调错误 */ } + } + break; + } + + case PacketType.Status: + case PacketType.Interrupt: + { + var notif = StatusNotification.FromPacket(packet); + StatusQueue.Enqueue(notif); + StatusCallback cb; + lock (_stateLock) { _statusCallbacks.TryGetValue((notif.StatusName, notif.ModuleName), out cb); } + if (cb != null) + { + try { cb(notif); } catch { /* 吞掉回调错误 */ } + } + break; + } + + case PacketType.Error: + { + var err = ProtocolCodec.ParseServerError(packet); + _respTcs?.TrySetResult(err); + _cmdRespTcs?.TrySetResult(err); + break; + } + + case PacketType.Info: + // 静默丢弃(欢迎/再见消息)。 + break; + + case PacketType.Cmd: + // 服务器永远不应发送 CMD;为向前兼容性忽略。 + break; + } + } + + internal void OnDisconnect() + { + UnblockWaiters(new RouterConnectionException("Connection lost unexpectedly.")); + } + } + + // ----------------------------------------------------------------------- + // 小型便利扩展 + // ----------------------------------------------------------------------- + + internal static class TaskExtensions + { + public static async Task ContinueWithText(this Task task) + { + var resp = await task.ConfigureAwait(false); + return resp.Text; + } + } +} diff --git a/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.csproj b/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.csproj new file mode 100644 index 0000000..b3d7c2b --- /dev/null +++ b/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.csproj @@ -0,0 +1,51 @@ + + + + + netstandard2.0;net8.0 + latest + disable + false + true + $(NoWarn);CS1591 + + + true + CsmTcpRouter.Client + 0.1.0 + 0.1.0.0 + 0.1.0.0 + NEVSTOP-LAB + NEVSTOP-LAB + CsmTcpRouter.Client + CSM-TCP-Router .NET Client + C# client SDK for the CSM-TCP-Router LabVIEW server. Speaks the CSM-TCP-Router protocol v0 over TCP and exposes a high-level synchronous and asynchronous API for sending commands, awaiting responses, and subscribing to status / interrupt broadcasts from a Communicable State Machine (CSM). + Copyright (c) 2026 NEVSTOP-LAB + MIT + https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App + https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App + git + csm;labview;tcp;router;client;sdk;daq;communicable-state-machine;async + README.md + See CHANGELOG.md for release notes. + true + snupkg + true + true + + + + + + + + + + + true + + + diff --git a/SDK/csharp/tests/CsmTcpRouter.Tests/ClientIntegrationTests.cs b/SDK/csharp/tests/CsmTcpRouter.Tests/ClientIntegrationTests.cs new file mode 100644 index 0000000..31f531f --- /dev/null +++ b/SDK/csharp/tests/CsmTcpRouter.Tests/ClientIntegrationTests.cs @@ -0,0 +1,363 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace CsmTcpRouter.Tests +{ + /// + /// 针对真实回环 的端到端客户端测试。 + /// 镜像 SDK/python/tests/test_integration.py 以及 test_client.py 的部分内容。 + /// + public class ClientIntegrationTests + { + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(2); + + /// + /// 将 TcpListener 绑定到端口 0(由操作系统分配),获取端口号,然后停止 + /// 监听器。在测试期间该端口几乎可以确定是关闭的,因此我们可以依赖连接 + /// 尝试失败,而无需依赖系统状态(例如,端口 1 可能是开放的)。 + /// + private static int GetClosedPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + // --------------------------------------------------------------- + // 连接 / 断开连接 + // --------------------------------------------------------------- + + [Fact] + public void Connect_Disconnect_RoundTrip() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + Assert.True(client.Connected); + client.Disconnect(); + Assert.False(client.Connected); + } + + [Fact] + public async Task ConnectAsync_RoundTrip() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + await client.ConnectAsync(server.Host, server.Port, DefaultTimeout); + Assert.True(client.Connected); + client.Disconnect(); + } + + [Fact] + public void Connect_Twice_Throws() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + Assert.Throws( + () => client.Connect(server.Host, server.Port, DefaultTimeout)); + } + + [Fact] + public void Connect_BadPort_Throws() + { + using var client = new TcpRouterClient(); + int closedPort = GetClosedPort(); + Assert.Throws( + () => client.Connect("127.0.0.1", closedPort, TimeSpan.FromMilliseconds(500))); + } + + // --------------------------------------------------------------- + // SendAndWait / 内置方法 + // --------------------------------------------------------------- + + [Fact] + public void Ping_ReturnsTrueAndElapsed() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + var (ok, elapsed) = client.Ping(DefaultTimeout); + Assert.True(ok); + Assert.True(elapsed >= TimeSpan.Zero); + } + + [Fact] + public void ListModules_ReturnsServerText() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + var modules = client.ListModules(DefaultTimeout); + Assert.Contains("AI", modules); + Assert.Contains("DIO", modules); + } + + [Fact] + public void ListApi_FormatsCommand() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + var api = client.ListApi("DAQmx", DefaultTimeout); + Assert.Contains("DAQmx", api); + } + + [Fact] + public void SendAndWait_CustomResponse() + { + using var server = new MockServer(); + server.Start(); + server.SetResponse("Custom Cmd", "Custom Reply"); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + var resp = client.SendAndWait("Custom Cmd", DefaultTimeout); + Assert.Equal("Custom Reply", resp.Text); + } + + [Fact] + public void SendAndWait_ServerError_Throws() + { + using var server = new MockServer(); + server.Start(); + server.SetErrorResponse("Bad Cmd", "[Error: 42] bad"); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + var ex = Assert.Throws(() => client.SendAndWait("Bad Cmd", DefaultTimeout)); + Assert.Equal("42", ex.Code); + Assert.Equal("bad", ex.ServerMessage); + } + + [Fact] + public void SendAndWait_Timeout_Throws() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + // 服务器默认对未知命令回复 CmdResp,而不是 Resp, + // 因此 SendAndWait 在等待 Resp 时会超时。 + Assert.Throws( + () => client.SendAndWait("Unknown XYZ", TimeSpan.FromMilliseconds(200))); + } + + [Fact] + public void SendAndWait_NotConnected_Throws() + { + using var client = new TcpRouterClient(); + Assert.Throws( + () => client.SendAndWait("Ping", DefaultTimeout)); + } + + // --------------------------------------------------------------- + // Post(异步 cmd-resp 握手) + // --------------------------------------------------------------- + + [Fact] + public void Post_CompletesOnHandshake() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + client.Post("API: Start -> DAQmx", DefaultTimeout); + // 确保服务器实际收到了命令。 + string cmd = server.GetReceived(DefaultTimeout); + Assert.Equal("API: Start -> DAQmx", cmd); + } + + [Fact] + public void PostNoReply_CompletesOnHandshake() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + client.PostNoReply("API: Reset ->| DAQmx", DefaultTimeout); + string cmd = server.GetReceived(DefaultTimeout); + Assert.Equal("API: Reset ->| DAQmx", cmd); + } + + // --------------------------------------------------------------- + // 订阅 / 状态广播 + // --------------------------------------------------------------- + + [Fact] + public void SubscribeStatus_DeliversToCallbackAndQueue() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + + StatusNotification received = null; + using var ev = new ManualResetEventSlim(); + client.SubscribeStatus("Status", "AI", n => { received = n; ev.Set(); }, DefaultTimeout); + + server.PushStatus("Status >> v1 <- AI"); + + Assert.True(ev.Wait(DefaultTimeout), "callback was not invoked in time"); + Assert.Equal("Status", received.StatusName); + Assert.Equal("v1", received.Data); + Assert.Equal("AI", received.ModuleName); + + Assert.True(client.StatusQueue.TryDequeue(out var queued)); + Assert.Equal("Status", queued.StatusName); + } + + [Fact] + public void UnsubscribeStatus_RemovesCallback() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + + int hits = 0; + using var ev = new ManualResetEventSlim(); + client.SubscribeStatus( + "Status", "AI", + _ => { Interlocked.Increment(ref hits); ev.Set(); }, + DefaultTimeout); + client.UnsubscribeStatus("Status", "AI", DefaultTimeout); + + server.PushStatus("Status >> v1 <- AI"); + Assert.False(ev.Wait(TimeSpan.FromMilliseconds(150)), "callback was invoked after unsubscribe"); + Assert.Equal(0, hits); + } + + [Fact] + public void RegisterAsyncCallback_DeliversAsyncResponse() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + + AsyncResponse received = null; + using var ev = new ManualResetEventSlim(); + client.RegisterAsyncCallback("API: Start -> DIO", ar => { received = ar; ev.Set(); }); + + server.PushAsyncResponse("done <- API: Start -> DIO"); + + Assert.True(ev.Wait(DefaultTimeout), "async callback not invoked"); + Assert.Equal("done", received.Text); + Assert.Equal("API: Start -> DIO", received.OriginalCommand); + } + + // --------------------------------------------------------------- + // 等待期间断开连接会解除等待者的阻塞 + // --------------------------------------------------------------- + + [Fact] + public void Disconnect_WhileWaiting_RaisesConnectionError() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + client.Connect(server.Host, server.Port, DefaultTimeout); + + // 发起一个永远不会收到响应的 SendAndWait;在等待期间断开连接。 + var task = Task.Run(() => client.SendAndWait("Unknown XYZ", TimeSpan.FromSeconds(10))); + Thread.Sleep(100); + client.Disconnect(); + var agg = Assert.Throws(() => task.Wait(TimeSpan.FromSeconds(2))); + Assert.IsType(agg.InnerException); + } + + // --------------------------------------------------------------- + // WaitForServer + // --------------------------------------------------------------- + + [Fact] + public void WaitForServer_ReturnsTrueWhenReady() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + bool ready = client.WaitForServer(server.Host, server.Port, TimeSpan.FromSeconds(2), TimeSpan.FromMilliseconds(50)); + Assert.True(ready); + } + + [Fact] + public void WaitForServer_ReturnsFalseOnTimeout() + { + using var client = new TcpRouterClient(); + int closedPort = GetClosedPort(); + bool ready = client.WaitForServer("127.0.0.1", closedPort, TimeSpan.FromMilliseconds(300), TimeSpan.FromMilliseconds(50)); + Assert.False(ready); + } + + // --------------------------------------------------------------- + // 异步 API + // --------------------------------------------------------------- + + [Fact] + public async Task SendAndWaitAsync_RoundTrip() + { + using var server = new MockServer(); + server.Start(); + server.SetResponse("Hello", "World"); + using var client = new TcpRouterClient(); + await client.ConnectAsync(server.Host, server.Port, DefaultTimeout); + var resp = await client.SendAndWaitAsync("Hello", DefaultTimeout); + Assert.Equal("World", resp.Text); + } + + [Fact] + public async Task PingAsync_ReturnsOkAndElapsed() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + await client.ConnectAsync(server.Host, server.Port, DefaultTimeout); + var (ok, elapsed) = await client.PingAsync(DefaultTimeout); + Assert.True(ok); + Assert.True(elapsed >= TimeSpan.Zero); + } + + [Fact] + public async Task ListModulesAsync_ReturnsText() + { + using var server = new MockServer(); + server.Start(); + using var client = new TcpRouterClient(); + await client.ConnectAsync(server.Host, server.Port, DefaultTimeout); + string modules = await client.ListModulesAsync(DefaultTimeout); + Assert.Contains("AI", modules); + } + + [Fact] + public async Task SendAndWaitAsync_SerialisesConcurrentCallers() + { + using var server = new MockServer(); + server.Start(); + server.SetResponse("A", "alpha"); + server.SetResponse("B", "bravo"); + using var client = new TcpRouterClient(); + await client.ConnectAsync(server.Host, server.Port, DefaultTimeout); + + var tasks = Enumerable.Range(0, 10).Select(i => + client.SendAndWaitAsync(i % 2 == 0 ? "A" : "B", DefaultTimeout)).ToArray(); + var results = await Task.WhenAll(tasks); + for (int i = 0; i < tasks.Length; i++) + { + Assert.Equal(i % 2 == 0 ? "alpha" : "bravo", results[i].Text); + } + } + } +} diff --git a/SDK/csharp/tests/CsmTcpRouter.Tests/CsmTcpRouter.Tests.csproj b/SDK/csharp/tests/CsmTcpRouter.Tests/CsmTcpRouter.Tests.csproj new file mode 100644 index 0000000..3713b48 --- /dev/null +++ b/SDK/csharp/tests/CsmTcpRouter.Tests/CsmTcpRouter.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + latest + disable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/SDK/csharp/tests/CsmTcpRouter.Tests/MockServer.cs b/SDK/csharp/tests/CsmTcpRouter.Tests/MockServer.cs new file mode 100644 index 0000000..40779ce --- /dev/null +++ b/SDK/csharp/tests/CsmTcpRouter.Tests/MockServer.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using CsmTcpRouter; + +namespace CsmTcpRouter.Tests +{ + /// 用于测试的最小化 TCP 服务器,模拟 CSM-TCP-Router。 + /// 镜像 Python tests/conftest.py MockServer。 + internal sealed class MockServer : IDisposable + { + private TcpListener _listener; + private CancellationTokenSource _cts; + private Task _acceptTask; + + public string Host => "127.0.0.1"; + public int Port { get; private set; } + + public BlockingCollection ReceivedCommands { get; } + = new BlockingCollection(new ConcurrentQueue()); + + private readonly Dictionary _responses + = new Dictionary(); + private readonly object _stateLock = new object(); + + private readonly List _clients = new List(); + + public void Start() + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + Port = ((IPEndPoint)_listener.LocalEndpoint).Port; + _cts = new CancellationTokenSource(); + _acceptTask = Task.Run(() => AcceptLoopAsync(_cts.Token)); + } + + public void Stop() + { + try { _cts?.Cancel(); } catch { } + try { _listener?.Stop(); } catch { } + lock (_stateLock) + { + foreach (var c in _clients) + { + try { c.Close(); } catch { } + } + _clients.Clear(); + } + try { _acceptTask?.Wait(TimeSpan.FromSeconds(2)); } catch { } + } + + public void Dispose() => Stop(); + + public void SetResponse(string cmd, string respText) + { + lock (_stateLock) { _responses[cmd] = (PacketType.Resp, Encoding.UTF8.GetBytes(respText)); } + } + + public void SetErrorResponse(string cmd, string errorText) + { + lock (_stateLock) { _responses[cmd] = (PacketType.Error, Encoding.UTF8.GetBytes(errorText)); } + } + + public void PushStatus(string payload) + { + byte[] wire = ProtocolCodec.EncodePacket(Encoding.UTF8.GetBytes(payload), PacketType.Status); + List snapshot; + lock (_stateLock) { snapshot = new List(_clients); } + foreach (var c in snapshot) + { + try { c.GetStream().Write(wire, 0, wire.Length); } + catch { /* 忽略 */ } + } + } + + public void PushAsyncResponse(string payload) + { + byte[] wire = ProtocolCodec.EncodePacket(Encoding.UTF8.GetBytes(payload), PacketType.AsyncResp); + List snapshot; + lock (_stateLock) { snapshot = new List(_clients); } + foreach (var c in snapshot) + { + try { c.GetStream().Write(wire, 0, wire.Length); } + catch { /* 忽略 */ } + } + } + + public string GetReceived(TimeSpan? timeout = null) + { + if (ReceivedCommands.TryTake(out var cmd, (int)(timeout ?? TimeSpan.FromSeconds(1)).TotalMilliseconds)) + return cmd; + return null; + } + + // ----------------------------------------------------------------- + // 内部方法 + // ----------------------------------------------------------------- + + private async Task AcceptLoopAsync(CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested) + { + TcpClient client; + try { client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false); } + catch (ObjectDisposedException) { break; } + catch (SocketException) { break; } + lock (_stateLock) { _clients.Add(client); } + _ = Task.Run(() => HandleClientAsync(client, ct)); + } + } + catch { /* 忽略 */ } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken ct) + { + try + { + var stream = client.GetStream(); + // 欢迎信息数据包 + var welcome = ProtocolCodec.EncodePacket(Encoding.UTF8.GetBytes("Welcome to mock server"), PacketType.Info); + await stream.WriteAsync(welcome, 0, welcome.Length, ct).ConfigureAwait(false); + + var headerBuf = new byte[8]; + while (!ct.IsCancellationRequested) + { + if (!await ReadExactlyAsync(stream, headerBuf, 0, headerBuf.Length, ct).ConfigureAwait(false)) + break; + uint dataLen = ((uint)headerBuf[0] << 24) | ((uint)headerBuf[1] << 16) | ((uint)headerBuf[2] << 8) | headerBuf[3]; + var body = dataLen == 0 ? Array.Empty() : new byte[dataLen]; + if (dataLen > 0 && !await ReadExactlyAsync(stream, body, 0, body.Length, ct).ConfigureAwait(false)) + break; + byte typeByte = headerBuf[5]; + if (typeByte == (byte)PacketType.Cmd) + { + string cmdText = Encoding.UTF8.GetString(body).Trim(); + ReceivedCommands.Add(cmdText, ct); + HandleCommand(stream, cmdText); + } + } + } + catch (IOException) { } + catch (ObjectDisposedException) { } + catch (OperationCanceledException) { } + finally + { + lock (_stateLock) { _clients.Remove(client); } + try { client.Close(); } catch { } + } + } + + private void HandleCommand(NetworkStream stream, string cmd) + { + (PacketType Type, byte[] Data) custom; + bool hasCustom; + lock (_stateLock) { hasCustom = _responses.TryGetValue(cmd, out custom); } + if (hasCustom) + { + var wire = ProtocolCodec.EncodePacket(custom.Data, custom.Type); + stream.Write(wire, 0, wire.Length); + return; + } + + byte[] reply; + if (cmd == "Ping") + reply = ProtocolCodec.EncodePacket(Encoding.UTF8.GetBytes("Pong"), PacketType.Resp); + else if (cmd == "List") + reply = ProtocolCodec.EncodePacket(Encoding.UTF8.GetBytes("AI\nDIO\nSystem"), PacketType.Resp); + else if (cmd.StartsWith("List API ", StringComparison.Ordinal)) + { + string module = cmd.Substring("List API ".Length).Trim(); + reply = ProtocolCodec.EncodePacket( + Encoding.UTF8.GetBytes($"API: Start -> {module}\nAPI: Stop -> {module}"), + PacketType.Resp); + } + else if (cmd.StartsWith("List State ", StringComparison.Ordinal)) + { + string module = cmd.Substring("List State ".Length).Trim(); + reply = ProtocolCodec.EncodePacket( + Encoding.UTF8.GetBytes($"Idle <- {module}\nRunning <- {module}"), + PacketType.Resp); + } + else if (cmd.Contains("->") || cmd.Contains("->")) + { + reply = ProtocolCodec.EncodePacket(Array.Empty(), PacketType.CmdResp); + } + else + { + // 对其他任何命令进行通用异步握手。 + reply = ProtocolCodec.EncodePacket(Array.Empty(), PacketType.CmdResp); + } + + stream.Write(reply, 0, reply.Length); + } + + private static async Task ReadExactlyAsync(Stream s, byte[] buf, int off, int count, CancellationToken ct) + { + int read = 0; + while (read < count) + { + int n; + try { n = await s.ReadAsync(buf, off + read, count - read, ct).ConfigureAwait(false); } + catch (IOException) { return false; } + catch (ObjectDisposedException) { return false; } + if (n == 0) return false; + read += n; + } + return true; + } + } +} diff --git a/SDK/csharp/tests/CsmTcpRouter.Tests/ProtocolTests.cs b/SDK/csharp/tests/CsmTcpRouter.Tests/ProtocolTests.cs new file mode 100644 index 0000000..9dbfdb8 --- /dev/null +++ b/SDK/csharp/tests/CsmTcpRouter.Tests/ProtocolTests.cs @@ -0,0 +1,306 @@ +using System; +using System.Text; +using CsmTcpRouter; +using Xunit; + +namespace CsmTcpRouter.Tests +{ + // 镜像 SDK/python/tests/test_protocol.py。 + public class ProtocolTests + { + private const int HeaderSize = 8; + private const byte ProtocolVersion = 0x01; + + private static (byte DataLenHi3, byte DataLenHi2, byte DataLenHi1, byte DataLenLo, + byte Version, byte Type, byte Flag1, byte Flag2) + ParseHeader(byte[] wire) + { + return (wire[0], wire[1], wire[2], wire[3], wire[4], wire[5], wire[6], wire[7]); + } + + private static uint ReadDataLen(byte[] wire) => + ((uint)wire[0] << 24) | ((uint)wire[1] << 16) | ((uint)wire[2] << 8) | wire[3]; + + // ------------------------------------------------------------------- + // EncodePacket + // ------------------------------------------------------------------- + + [Fact] + public void Encode_ReturnsHeaderPlusBody() + { + var data = Encoding.UTF8.GetBytes("hello"); + var wire = ProtocolCodec.EncodePacket(data, PacketType.Cmd); + Assert.Equal(HeaderSize + data.Length, wire.Length); + } + + [Fact] + public void Encode_HeaderFormat() + { + var data = Encoding.UTF8.GetBytes("hello"); + var wire = ProtocolCodec.EncodePacket(data, PacketType.Cmd); + Assert.Equal((uint)data.Length, ReadDataLen(wire)); + Assert.Equal(ProtocolVersion, wire[4]); + Assert.Equal((byte)PacketType.Cmd, wire[5]); + Assert.Equal(0, wire[6]); + Assert.Equal(0, wire[7]); + } + + [Fact] + public void Encode_BodyAppendedVerbatim() + { + var data = Encoding.UTF8.GetBytes("test payload"); + var wire = ProtocolCodec.EncodePacket(data, PacketType.Resp); + var slice = new byte[data.Length]; + Buffer.BlockCopy(wire, HeaderSize, slice, 0, data.Length); + Assert.Equal(data, slice); + } + + [Fact] + public void Encode_EmptyBody() + { + var wire = ProtocolCodec.EncodePacket(Array.Empty(), PacketType.CmdResp); + Assert.Equal(HeaderSize, wire.Length); + Assert.Equal(0u, ReadDataLen(wire)); + } + + [Fact] + public void Encode_CustomFlags() + { + var wire = ProtocolCodec.EncodePacket(new byte[] { 0x78 }, PacketType.Info, flag1: 0xAB, flag2: 0xCD); + Assert.Equal(0xAB, wire[6]); + Assert.Equal(0xCD, wire[7]); + } + + [Fact] + public void Encode_AllPacketTypes() + { + foreach (PacketType ptype in Enum.GetValues(typeof(PacketType))) + { + var wire = ProtocolCodec.EncodePacket(Encoding.UTF8.GetBytes("data"), ptype); + Assert.Equal((byte)ptype, wire[5]); + } + } + + [Fact] + public void Encode_Utf8CommandString() + { + const string cmd = "API: Start Sampling -@ DAQmx"; + var wire = ProtocolCodec.EncodePacket(Encoding.UTF8.GetBytes(cmd), PacketType.Cmd); + Assert.Equal(cmd, Encoding.UTF8.GetString(wire, HeaderSize, wire.Length - HeaderSize)); + } + + [Fact] + public void Encode_LargePayloadLengthField() + { + var data = new byte[1024]; + var wire = ProtocolCodec.EncodePacket(data, PacketType.Resp); + Assert.Equal(1024u, ReadDataLen(wire)); + } + + [Fact] + public void Encode_NullDataTreatedAsEmpty() + { + var wire = ProtocolCodec.EncodePacket(null, PacketType.Cmd); + Assert.Equal(HeaderSize, wire.Length); + Assert.Equal(0u, ReadDataLen(wire)); + } + + // ------------------------------------------------------------------- + // DecodeHeader + // ------------------------------------------------------------------- + + [Fact] + public void DecodeHeader_RoundTrip() + { + var wire = ProtocolCodec.EncodePacket(Encoding.UTF8.GetBytes("body"), PacketType.AsyncResp, flag1: 1, flag2: 2); + var header = new byte[HeaderSize]; + Buffer.BlockCopy(wire, 0, header, 0, HeaderSize); + var (dataLen, version, typeByte, flag1, flag2) = ProtocolCodec.DecodeHeader(header); + Assert.Equal(4u, dataLen); + Assert.Equal(ProtocolVersion, version); + Assert.Equal((byte)PacketType.AsyncResp, typeByte); + Assert.Equal(1, flag1); + Assert.Equal(2, flag2); + } + + [Fact] + public void DecodeHeader_WrongLengthThrows() + { + Assert.Throws(() => ProtocolCodec.DecodeHeader(new byte[7])); + } + + [Fact] + public void DecodeHeader_ZeroLengthThrows() + { + Assert.Throws(() => ProtocolCodec.DecodeHeader(Array.Empty())); + } + + // ------------------------------------------------------------------- + // ParsePacket + // ------------------------------------------------------------------- + + private static (byte[] Header, byte[] Body) MakeWire(byte[] data, PacketType ptype) + { + var wire = ProtocolCodec.EncodePacket(data, ptype); + var header = new byte[HeaderSize]; + var body = new byte[wire.Length - HeaderSize]; + Buffer.BlockCopy(wire, 0, header, 0, HeaderSize); + Buffer.BlockCopy(wire, HeaderSize, body, 0, body.Length); + return (header, body); + } + + [Fact] + public void Parse_BasicRoundTrip() + { + var (header, body) = MakeWire(Encoding.UTF8.GetBytes("hello"), PacketType.Resp); + var pkt = ProtocolCodec.ParsePacket(header, body); + Assert.Equal(PacketType.Resp, pkt.Type); + Assert.Equal("hello", Encoding.UTF8.GetString(pkt.Data)); + Assert.Equal(ProtocolVersion, pkt.Version); + } + + [Fact] + public void Parse_AllKnownTypes() + { + foreach (PacketType ptype in Enum.GetValues(typeof(PacketType))) + { + var (header, body) = MakeWire(Encoding.UTF8.GetBytes("data"), ptype); + var pkt = ProtocolCodec.ParsePacket(header, body); + Assert.Equal(ptype, pkt.Type); + } + } + + [Fact] + public void Parse_UnknownTypeMappedToInfo() + { + // 手动构造一个具有未知类型字节 (0xFF) 的数据包。 + var header = new byte[] { 0, 0, 0, 4, ProtocolVersion, 0xFF, 0, 0 }; + var body = Encoding.UTF8.GetBytes("data"); + var pkt = ProtocolCodec.ParsePacket(header, body); + Assert.Equal(PacketType.Info, pkt.Type); + } + + [Fact] + public void Parse_BodyLengthMismatchThrows() + { + var (header, _) = MakeWire(Encoding.UTF8.GetBytes("hello"), PacketType.Cmd); + Assert.Throws(() => ProtocolCodec.ParsePacket(header, Encoding.UTF8.GetBytes("hi"))); + } + + [Fact] + public void Parse_EmptyBody() + { + var (header, body) = MakeWire(Array.Empty(), PacketType.CmdResp); + var pkt = ProtocolCodec.ParsePacket(header, body); + Assert.Empty(pkt.Data); + } + + [Fact] + public void Parse_FlagsPreserved() + { + var wire = ProtocolCodec.EncodePacket(new byte[] { 0x78 }, PacketType.Status, flag1: 3, flag2: 7); + var header = new byte[HeaderSize]; + var body = new byte[wire.Length - HeaderSize]; + Buffer.BlockCopy(wire, 0, header, 0, HeaderSize); + Buffer.BlockCopy(wire, HeaderSize, body, 0, body.Length); + var pkt = ProtocolCodec.ParsePacket(header, body); + Assert.Equal(3, pkt.Flag1); + Assert.Equal(7, pkt.Flag2); + } + + [Fact] + public void Parse_HeaderTooShortThrows() + { + Assert.Throws(() => ProtocolCodec.ParsePacket(new byte[4], Array.Empty())); + } + + [Fact] + public void HeaderSize_IsEight() + { + Assert.Equal(8, ProtocolCodec.HeaderSize); + } + + // ------------------------------------------------------------------- + // ParseServerError + // ------------------------------------------------------------------- + + [Fact] + public void ParseServerError_PlainMessage() + { + var pkt = new Packet(PacketType.Error, Encoding.UTF8.GetBytes("something went wrong")); + var err = ProtocolCodec.ParseServerError(pkt); + Assert.Equal("something went wrong", err.ServerMessage); + Assert.Equal(string.Empty, err.Code); + } + + [Fact] + public void ParseServerError_CsmFormat() + { + var pkt = new Packet(PacketType.Error, Encoding.UTF8.GetBytes("[Error: 42] module not found")); + var err = ProtocolCodec.ParseServerError(pkt); + Assert.Equal("42", err.Code); + Assert.Equal("module not found", err.ServerMessage); + Assert.Equal("[Error: 42] module not found", err.ToString()); + } + + [Fact] + public void ParseServerError_CsmFormatNoMessage() + { + var pkt = new Packet(PacketType.Error, Encoding.UTF8.GetBytes("[Error: 0]")); + var err = ProtocolCodec.ParseServerError(pkt); + Assert.Equal("0", err.Code); + Assert.Equal(string.Empty, err.ServerMessage); + } + + [Fact] + public void ParseServerError_MalformedBracketNoCrash() + { + var pkt = new Packet(PacketType.Error, Encoding.UTF8.GetBytes("[Error: no closing bracket")); + var err = ProtocolCodec.ParseServerError(pkt); + Assert.Equal(string.Empty, err.Code); + Assert.Contains("no closing bracket", err.ServerMessage); + } + + // ------------------------------------------------------------------- + // 模型解析辅助方法 + // ------------------------------------------------------------------- + + [Fact] + public void AsyncResponse_FromPacket_SplitsOnSeparator() + { + var pkt = new Packet(PacketType.AsyncResp, Encoding.UTF8.GetBytes("result <- API: Start -> DIO")); + var resp = AsyncResponse.FromPacket(pkt); + Assert.Equal("result", resp.Text); + Assert.Equal("API: Start -> DIO", resp.OriginalCommand); + } + + [Fact] + public void AsyncResponse_FromPacket_NoSeparator() + { + var pkt = new Packet(PacketType.AsyncResp, Encoding.UTF8.GetBytes("just text")); + var resp = AsyncResponse.FromPacket(pkt); + Assert.Equal("just text", resp.Text); + Assert.Equal(string.Empty, resp.OriginalCommand); + } + + [Fact] + public void StatusNotification_FromPacket_FullForm() + { + var pkt = new Packet(PacketType.Status, Encoding.UTF8.GetBytes("Status >> value42 <- AI")); + var notif = StatusNotification.FromPacket(pkt); + Assert.Equal("Status", notif.StatusName); + Assert.Equal("value42", notif.Data); + Assert.Equal("AI", notif.ModuleName); + Assert.Equal(PacketType.Status, notif.PacketType); + } + + [Fact] + public void StatusNotification_FromPacket_InterruptType() + { + var pkt = new Packet(PacketType.Interrupt, Encoding.UTF8.GetBytes("Alarm >> fire <- Safety")); + var notif = StatusNotification.FromPacket(pkt); + Assert.Equal(PacketType.Interrupt, notif.PacketType); + Assert.Equal("Alarm", notif.StatusName); + } + } +} diff --git a/SDK/python/CHANGELOG.md b/SDK/python/CHANGELOG.md new file mode 100644 index 0000000..2183933 --- /dev/null +++ b/SDK/python/CHANGELOG.md @@ -0,0 +1,110 @@ +# Changelog + +All notable changes to `csm-tcp-router-client` are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [Unreleased] + +--- + +## [0.3.0] – 2026-04-27 + +### Changed + +- Consolidated the entire client SDK into a single importable module + `csm_tcp_router_client` (file: `src/csm_tcp_router_client.py`). The old + `csm_tcp_router` package directory and its sub-modules (`client`, + `async_client`, `models`, `exceptions`, `_protocol`, `_transport`, + `_errors`) have been removed. All public symbols (`TcpRouterClient`, + `AsyncTcpRouterClient`, exceptions, models, protocol helpers) are now + re-exported directly from the top-level `csm_tcp_router_client` module. +- **Breaking**: replace `from csm_tcp_router import …` with + `from csm_tcp_router_client import …`. Sub-module imports such as + `from csm_tcp_router.exceptions import …` or + `from csm_tcp_router.models import …` should also be replaced with + `from csm_tcp_router_client import …`. +- CI workflow `Python_SDK.yml` now triggers on changes under `SDK/python/**` + (previously the stale `SDK/python-package/**` path filter prevented the + publish jobs from firing); `working-directory` and the artifact upload + path were updated to match. +- `pyproject.toml` `Documentation` and `Changelog` URLs updated to point at + `SDK/python/` instead of the old `SDK/python-package/` path. + +--- + +## [0.2.0] – 2026-04-22 + +### Added + +- `AsyncTcpRouterClient` class: full asyncio API mirroring every method of + `TcpRouterClient`, using `asyncio.StreamReader`/`StreamWriter` and + `asyncio.Queue` for non-blocking I/O. +- Async context-manager support: `async with AsyncTcpRouterClient() as client:`. +- Both sync and `async def` callbacks supported for `subscribe_status()` and + `register_async_callback()` on the async client. +- `AsyncTcpRouterClient` exported from the top-level `csm_tcp_router` package. +- `examples/async_usage.py` – asyncio quickstart demonstrating all features. +- Test suite extended with `tests/test_async_client.py` (48 tests: unit + + integration via `MockServer`); test runner now uses `asyncio_mode = "auto"`. +- `pytest-asyncio` added to CI test dependencies. +- Chinese documentation: `README.zh-cn.md` (full translation of `README.md`). +- `README.md` updated with asyncio quickstart, async API reference table, link + to Chinese docs, and `async_usage.py` in the examples list. +- CI: added `publish-testpypi` job that publishes to TestPyPI *before* + `publish` (production PyPI); production publish now depends on TestPyPI + success; both use OIDC trusted publishing. +- `Framework :: AsyncIO` classifier added to package metadata. + +### Changed + +- Package version bumped to `0.2.0`. +- `asyncio_mode = "auto"` added to `pyproject.toml` pytest options; all async + tests run automatically without explicit `@pytest.mark.asyncio` decorators. + +--- + +## [0.1.0] – 2026-04-22 + +### Added + +- Initial release of the `csm-tcp-router-client` Python SDK. +- `TcpRouterClient` class with full thread-safe implementation of the + CSM-TCP-Router protocol v0. +- Connection lifecycle: `connect()`, `disconnect()`, `wait_for_server()`, + `connected` property, context-manager support. +- Synchronous command: `send_and_wait()`. +- Asynchronous command: `post()` with `CMD_RESP` handshake. +- No-reply async command: `post_no_reply()` with `CMD_RESP` handshake. +- Round-trip ping: `ping()`. +- Router management helpers: `list_modules()`, `list_api()`, `list_states()`, + `help()`. +- Status / interrupt subscriptions: `subscribe_status()`, + `unsubscribe_status()`, `register_async_callback()`, + `unregister_async_callback()`. +- Polling queues: `status_queue`, `async_response_queue`. +- Typed exception hierarchy: `TcpRouterError`, `ConnectionError`, + `TimeoutError`, `ProtocolError`, `ServerError` (with `.code` and `.message`). +- Public data models: `PacketType`, `Packet`, `CommandResponse`, + `AsyncResponse`, `StatusNotification`. +- Internal protocol v0 codec (`_protocol.py`) with `encode_packet()`, + `decode_header()`, `parse_packet()`; unknown packet types mapped to + `INFO` for forward compatibility. +- Internal TCP transport layer (`_transport.py`) with background daemon + receive thread, `memoryview`-based zero-copy reads, and clean shutdown. +- Comprehensive test suite: unit tests for protocol codec, unit tests for + client dispatch logic (mock transport), and integration tests against a + `MockServer` fixture. +- Examples: `basic_usage.py`, `subscribe_status.py`. +- `pyproject.toml` with `hatchling` build backend; ready for `pip install` + and upload to PyPI. +- GitHub Actions workflow `Python_SDK.yml`: lint (ruff), test (pytest) on + Python 3.8–3.12, build, and optional publish to PyPI on tag. + +[Unreleased]: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/compare/python-sdk-v0.3.0...HEAD +[0.3.0]: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/compare/python-sdk-v0.2.0...python-sdk-v0.3.0 +[0.2.0]: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/compare/python-sdk-v0.1.0...python-sdk-v0.2.0 +[0.1.0]: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/releases/tag/python-sdk-v0.1.0 diff --git a/SDK/python/LICENSE b/SDK/python/LICENSE new file mode 100644 index 0000000..78e1cd2 --- /dev/null +++ b/SDK/python/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NEVSTOP-LAB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SDK/python/README.md b/SDK/python/README.md new file mode 100644 index 0000000..16d3351 --- /dev/null +++ b/SDK/python/README.md @@ -0,0 +1,311 @@ +# csm-tcp-router-client + +[![PyPI](https://img.shields.io/pypi/v/csm-tcp-router-client)](https://pypi.org/project/csm-tcp-router-client/) +[![Python](https://img.shields.io/pypi/pyversions/csm-tcp-router-client)](https://pypi.org/project/csm-tcp-router-client/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![CI](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/Python_SDK.yml/badge.svg)](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/Python_SDK.yml) + +Python client SDK for the [CSM-TCP-Router](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App) LabVIEW server. + +CSM-TCP-Router exposes a LabVIEW [Communicable State Machine (CSM)](https://github.com/NEVSTOP-LAB/Communicable-State-Machine) application over TCP so that any TCP client—including Python scripts, test harnesses, or CI pipelines—can send commands and receive responses without touching the LabVIEW code. + +> 📖 [中文文档 README.zh-cn.md](README.zh-cn.md) + +--- + +## Installation + +```bash +pip install csm-tcp-router-client +``` + +Requires Python 3.8 or later. No third-party dependencies—only the Python standard library. + +--- + +## Quickstart + +### Synchronous client + +```python +from csm_tcp_router_client import TcpRouterClient + +with TcpRouterClient() as client: + client.connect("localhost", 30007) + + # List all loaded CSM modules + print(client.list_modules()) + + # Send a synchronous command and wait for the response + resp = client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + + # Ping the server + ok, elapsed_s = client.ping() + print(f"Ping: {ok}, latency={elapsed_s*1000:.1f} ms") +``` + +### Asyncio client + +```python +import asyncio +from csm_tcp_router_client import AsyncTcpRouterClient + +async def main(): + async with AsyncTcpRouterClient() as client: + await client.connect("localhost", 30007) + print(await client.list_modules()) + resp = await client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + +asyncio.run(main()) +``` + +--- + +## Features + +- **Synchronous commands** (`-@`) – `send_and_wait()` blocks until the server returns the response. +- **Asynchronous commands** (`->`) – `post()` waits for the `cmd-resp` handshake; the eventual response is delivered via callback or queue. +- **No-reply commands** (`->|`) – `post_no_reply()` waits for the `cmd-resp` handshake; no further response expected. +- **Status subscriptions** – `subscribe_status()` / `unsubscribe_status()` with optional callback or polling queue. +- **Router management helpers** – `list_modules()`, `list_api()`, `list_states()`, `help()`. +- **Connection utilities** – `wait_for_server()` for polling during app startup. +- **Thread-safe sync client** – `TcpRouterClient`: all methods may be called from multiple threads concurrently. +- **Asyncio client** – `AsyncTcpRouterClient`: full `async def` API with both sync and async callbacks supported. +- **Zero dependencies** – pure Python standard library. +- **Context manager** support (`with TcpRouterClient()` / `async with AsyncTcpRouterClient()`). + +--- + +## Protocol + +The SDK implements the CSM-TCP-Router **protocol v0**. + +``` +| Data Length (4B) | Version (1B) | TYPE (1B) | FLAG1 (1B) | FLAG2 (1B) | Text Data | +╰────────────────────────── Header (8B) ─────────────────────────────╯ +``` + +| TYPE byte | Name | Direction | Description | +|-----------|---------------|----------------|------------------------------------------------| +| `0x00` | `INFO` | Server → Client| Welcome / goodbye informational message | +| `0x01` | `ERROR` | Server → Client| CSM error: `[Error: ] ` | +| `0x02` | `CMD` | Client → Server| Command string | +| `0x03` | `CMD_RESP` | Server → Client| Handshake ACK for async / subscribe commands | +| `0x04` | `RESP` | Server → Client| Synchronous response payload | +| `0x05` | `ASYNC_RESP` | Server → Client| Async response: ` <- ` | +| `0x06` | `STATUS` | Server → Client| Status broadcast: ` >> <- ` | +| `0x07` | `INTERRUPT` | Server → Client| Interrupt broadcast (same format as STATUS) | + +### Communication flows + +**Synchronous (`-@`)** + +``` +Client ─── CMD ──────────────────► Server +Client ◄── RESP (or ERROR) ─────── Server +``` + +**Asynchronous (`->`)** + +``` +Client ─── CMD ──────────────────► Server +Client ◄── CMD_RESP (or ERROR) ─── Server ← handshake +Client ◄── ASYNC_RESP ──────────── Server ← later, async result +``` + +**No-reply (`->|`)** + +``` +Client ─── CMD ──────────────────► Server +Client ◄── CMD_RESP (or ERROR) ─── Server ← handshake; no further reply +``` + +**Subscribe / unsubscribe** + +``` +Client ─── CMD () ─────► Server +Client ◄── CMD_RESP (or ERROR) ─── Server + … (whenever the CSM module emits the status) … +Client ◄── STATUS ──────────────── Server +Client ─── CMD () ───► Server +Client ◄── CMD_RESP ─────────────── Server +``` + +--- + +## API Reference + +### `TcpRouterClient` (sync) + +#### Connection + +| Method | Description | +|---|---| +| `connect(host, port, timeout=5.0)` | Connect to the server; raises `ConnectionError` on failure. | +| `disconnect()` | Close the connection; safe to call even when not connected. | +| `connected` | `True` when the transport is connected. | +| `wait_for_server(host, port, timeout=30, retry_interval=0.5)` | Poll until the server is reachable; returns `True`/`False`. | + +#### Commands + +| Method | Description | +|---|---| +| `send_and_wait(command, timeout=5.0) → CommandResponse` | Synchronous command (`-@`); blocks until `RESP` arrives. | +| `post(command, timeout=5.0)` | Async command (`->`); waits for `CMD_RESP` handshake. | +| `post_no_reply(command, timeout=5.0)` | No-reply command (`->|`); waits for `CMD_RESP` handshake. | +| `ping(timeout=2.0) → (bool, float)` | Round-trip latency check. | + +#### Router management helpers + +| Method | Description | +|---|---| +| `list_modules(timeout=5.0) → str` | `List` command result. | +| `list_api(module, timeout=5.0) → str` | `List API ` result. | +| `list_states(module, timeout=5.0) → str` | `List State ` result. | +| `help(module, timeout=5.0) → str` | `Help ` result. | + +#### Subscriptions + +| Method | Description | +|---|---| +| `subscribe_status(status_name, module_name, callback=None, timeout=5.0)` | Subscribe; optional callback invoked per notification. | +| `unsubscribe_status(status_name, module_name, timeout=5.0)` | Unsubscribe. | +| `register_async_callback(original_command, callback)` | Register a callback for `ASYNC_RESP` packets. | +| `unregister_async_callback(original_command)` | Remove an async callback. | + +#### Queues (polling alternative to callbacks) + +| Attribute | Type | Description | +|---|---|---| +| `status_queue` | `Queue[StatusNotification]` | Receive status/interrupt broadcasts by polling. | +| `async_response_queue` | `Queue[AsyncResponse]` | Receive async responses by polling. | + +--- + +### `AsyncTcpRouterClient` (asyncio) + +All methods are `async def` coroutines; use `await` to call them. + +#### Connection + +| Method | Description | +|---|---| +| `await connect(host, port, timeout=5.0)` | Open a TCP connection; raises `ConnectionError` on failure. | +| `await disconnect()` | Close the connection; safe to call when not connected. | +| `connected` | `True` when the writer is open. | +| `await wait_for_server(host, port, timeout=30, retry_interval=0.5)` | Poll until the server is reachable. | + +#### Commands + +| Method | Description | +|---|---| +| `await send_and_wait(command, timeout=5.0) → CommandResponse` | Synchronous command (`-@`). | +| `await post(command, timeout=5.0)` | Async command (`->`). | +| `await post_no_reply(command, timeout=5.0)` | No-reply command (`->|`). | +| `await ping(timeout=2.0) → (bool, float)` | Round-trip latency check. | + +#### Router management helpers + +Same as sync client but all methods are `async def`. + +#### Subscriptions + +| Method | Description | +|---|---| +| `await subscribe_status(status_name, module_name, callback=None, timeout=5.0)` | Subscribe; callback may be sync or `async def`. | +| `await unsubscribe_status(status_name, module_name, timeout=5.0)` | Unsubscribe. | +| `register_async_callback(original_command, callback)` | Register callback for `ASYNC_RESP`; may be sync or `async def`. | +| `unregister_async_callback(original_command)` | Remove callback. | + +#### Queues + +| Attribute | Type | Description | +|---|---|---| +| `status_queue` | `asyncio.Queue[StatusNotification]` | Available after `connect()`; poll with `await queue.get()`. | +| `async_response_queue` | `asyncio.Queue[AsyncResponse]` | Available after `connect()`. | + +--- + +### Data models + +#### `CommandResponse` +- `.raw: bytes` – raw server payload +- `.text: str` – UTF-8 decoded text + +#### `AsyncResponse` +- `.raw: bytes`, `.text: str` +- `.original_command: str` – the command echoed by the server + +#### `StatusNotification` +- `.raw: bytes` +- `.packet_type: PacketType` – `STATUS` or `INTERRUPT` +- `.status_name: str` – e.g. `"Status"` +- `.data: str` – the broadcasted value +- `.module_name: str` – the sending CSM module + +### Exceptions + +| Exception | Raised when | +|---|---| +| `TcpRouterError` | Base class for all SDK exceptions | +| `ConnectionError` | TCP connection fails or is lost | +| `TimeoutError` | No response within the timeout window | +| `ProtocolError` | Invalid or unexpected wire frame | +| `ServerError` | Server returns an `ERROR` packet; `.code` and `.message` attributes available | + +--- + +## Examples + +See the [`examples/`](examples/) directory: + +- [`basic_usage.py`](examples/basic_usage.py) – sync client: connect, ping, list modules, send commands. +- [`subscribe_status.py`](examples/subscribe_status.py) – sync client: real-time status subscription with callback. +- [`async_usage.py`](examples/async_usage.py) – asyncio client: all features using `async def` / `await`. + +--- + +## Migration from the script SDK + +The previous single-file SDK (`SDK/PythonClientAPI/tcp_router_client.py`) is +still available but is not pip-installable and uses a different packet-type +numbering (aligned with protocol v1-draft rather than the published v0 spec). + +| Old method | New method | Notes | +|---|---|---| +| `connect()` | `connect()` | Returns `None`; raises `ConnectionError` instead of returning `False` | +| `disconnect()` | `disconnect()` | Unchanged | +| `send_message_and_wait_for_reply(msg)` | `send_and_wait(cmd)` | Returns `CommandResponse`; raises on error | +| `post_message(msg)` | `post(cmd)` | Waits for `CMD_RESP` handshake | +| `post_no_rep_message(msg)` | `post_no_reply(cmd)` | Waits for `CMD_RESP` handshake | +| `ping()` | `ping()` | Same signature | +| `register_status_change(s, m, cb)` | `subscribe_status(s, m, callback=cb)` | Raises on error instead of returning `False` | +| `unregister_status_change(s, m)` | `unsubscribe_status(s, m)` | Raises on error | +| `wait_for_server(h, p, t)` | `wait_for_server(h, p, timeout=t)` | Keyword arg | +| `obtain()` / `release()` | Use context manager `with TcpRouterClient() as c:` | – | + +--- + +## Development + +```bash +# Install dev dependencies +pip install -e ".[dev]" +# or +pip install hatchling pytest pytest-asyncio ruff + +# Run tests (sync + async) +pytest + +# Lint +ruff check src/ tests/ +``` + +--- + +## License + +[MIT](LICENSE) — © NEVSTOP-LAB + diff --git a/SDK/python/README.zh-cn.md b/SDK/python/README.zh-cn.md new file mode 100644 index 0000000..4e19ae1 --- /dev/null +++ b/SDK/python/README.zh-cn.md @@ -0,0 +1,308 @@ +# csm-tcp-router-client + +[![PyPI](https://img.shields.io/pypi/v/csm-tcp-router-client)](https://pypi.org/project/csm-tcp-router-client/) +[![Python](https://img.shields.io/pypi/pyversions/csm-tcp-router-client)](https://pypi.org/project/csm-tcp-router-client/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![CI](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/Python_SDK.yml/badge.svg)](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/Python_SDK.yml) + +[CSM-TCP-Router](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App) LabVIEW 服务器的 Python 客户端 SDK。 + +CSM-TCP-Router 将 LabVIEW [可通信状态机(CSM)](https://github.com/NEVSTOP-LAB/Communicable-State-Machine) 应用通过 TCP 对外暴露,使任意 TCP 客户端(Python 脚本、测试框架、CI 流水线等)无需修改 LabVIEW 代码即可发送指令并接收响应。 + +> 📖 [English README](README.md) + +--- + +## 安装 + +```bash +pip install csm-tcp-router-client +``` + +要求 Python 3.8 或更高版本,无第三方依赖——仅依赖 Python 标准库。 + +--- + +## 快速入门 + +### 同步客户端 + +```python +from csm_tcp_router_client import TcpRouterClient + +with TcpRouterClient() as client: + client.connect("localhost", 30007) + + # 获取已加载的 CSM 模块列表 + print(client.list_modules()) + + # 发送同步指令并等待响应 + resp = client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + + # Ping 服务器 + ok, elapsed_s = client.ping() + print(f"Ping: {ok}, 延迟={elapsed_s*1000:.1f} ms") +``` + +### 异步客户端(asyncio) + +```python +import asyncio +from csm_tcp_router_client import AsyncTcpRouterClient + +async def main(): + async with AsyncTcpRouterClient() as client: + await client.connect("localhost", 30007) + print(await client.list_modules()) + resp = await client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + +asyncio.run(main()) +``` + +--- + +## 功能特性 + +- **同步指令**(`-@`)——`send_and_wait()` 阻塞直到服务器返回响应。 +- **异步指令**(`->`)——`post()` 等待 `cmd-resp` 握手包;最终响应通过回调或队列传递。 +- **无响应指令**(`->|`)——`post_no_reply()` 等待 `cmd-resp` 握手包;不再有后续响应。 +- **状态订阅**——`subscribe_status()` / `unsubscribe_status()`,支持可选回调或轮询队列。 +- **路由器管理助手**——`list_modules()`、`list_api()`、`list_states()`、`help()`。 +- **连接工具**——`wait_for_server()` 在应用启动期间轮询等待服务器就绪。 +- **线程安全的同步客户端**——`TcpRouterClient`:所有方法均可从多个线程并发调用。 +- **异步客户端**——`AsyncTcpRouterClient`:完整的 `async def` API,支持同步和异步回调。 +- **零第三方依赖**——纯 Python 标准库实现。 +- **上下文管理器**支持(`with TcpRouterClient()` / `async with AsyncTcpRouterClient()`)。 + +--- + +## 通信协议 + +本 SDK 实现了 CSM-TCP-Router **v0 协议**。 + +``` +| 数据长度 (4B) | 版本 (1B) | TYPE (1B) | FLAG1 (1B) | FLAG2 (1B) | 文本数据 | +╰────────────────────────── 头部 (8B) ────────────────────────────────╯ +``` + +| TYPE 字节 | 名称 | 方向 | 描述 | +|-----------|---------------|----------------|---------------------------------------------------| +| `0x00` | `INFO` | 服务器 → 客户端 | 欢迎 / 再见等信息报文 | +| `0x01` | `ERROR` | 服务器 → 客户端 | CSM 错误:`[Error: ] ` | +| `0x02` | `CMD` | 客户端 → 服务器 | 指令字符串 | +| `0x03` | `CMD_RESP` | 服务器 → 客户端 | 异步 / 订阅指令的握手确认包 | +| `0x04` | `RESP` | 服务器 → 客户端 | 同步响应负载 | +| `0x05` | `ASYNC_RESP` | 服务器 → 客户端 | 异步响应:`<数据> <- <原始指令>` | +| `0x06` | `STATUS` | 服务器 → 客户端 | 状态广播:`<名称> >> <数据> <- <模块>` | +| `0x07` | `INTERRUPT` | 服务器 → 客户端 | 中断广播(格式与 STATUS 相同) | + +### 通信流程 + +**同步(`-@`)** + +``` +客户端 ─── CMD ──────────────────► 服务器 +客户端 ◄── RESP(或 ERROR)─────── 服务器 +``` + +**异步(`->`)** + +``` +客户端 ─── CMD ──────────────────► 服务器 +客户端 ◄── CMD_RESP(或 ERROR)─── 服务器 ← 握手 +客户端 ◄── ASYNC_RESP ──────────── 服务器 ← 稍后,异步结果 +``` + +**无响应(`->|`)** + +``` +客户端 ─── CMD ──────────────────► 服务器 +客户端 ◄── CMD_RESP(或 ERROR)─── 服务器 ← 握手;无后续响应 +``` + +**订阅 / 取消订阅** + +``` +客户端 ─── CMD () ─────► 服务器 +客户端 ◄── CMD_RESP(或 ERROR)─── 服务器 + …(CSM 模块每次发出状态时)… +客户端 ◄── STATUS ──────────────── 服务器 +客户端 ─── CMD () ───► 服务器 +客户端 ◄── CMD_RESP ─────────────── 服务器 +``` + +--- + +## API 参考 + +### `TcpRouterClient`(同步) + +#### 连接管理 + +| 方法 | 描述 | +|---|---| +| `connect(host, port, timeout=5.0)` | 连接服务器;失败时抛出 `ConnectionError`。 | +| `disconnect()` | 关闭连接;即使未连接也可安全调用。 | +| `connected` | 已连接时为 `True`。 | +| `wait_for_server(host, port, timeout=30, retry_interval=0.5)` | 轮询直到服务器可达;返回 `True`/`False`。 | + +#### 指令方法 + +| 方法 | 描述 | +|---|---| +| `send_and_wait(command, timeout=5.0) → CommandResponse` | 同步指令(`-@`);阻塞直到 `RESP` 到达。 | +| `post(command, timeout=5.0)` | 异步指令(`->`);等待 `CMD_RESP` 握手。 | +| `post_no_reply(command, timeout=5.0)` | 无响应指令(`->|`);等待 `CMD_RESP` 握手。 | +| `ping(timeout=2.0) → (bool, float)` | 往返延迟检测。 | + +#### 路由器管理助手 + +| 方法 | 描述 | +|---|---| +| `list_modules(timeout=5.0) → str` | 执行 `List` 指令,返回模块列表。 | +| `list_api(module, timeout=5.0) → str` | 执行 `List API ` 指令。 | +| `list_states(module, timeout=5.0) → str` | 执行 `List State ` 指令。 | +| `help(module, timeout=5.0) → str` | 执行 `Help ` 指令。 | + +#### 订阅管理 + +| 方法 | 描述 | +|---|---| +| `subscribe_status(status_name, module_name, callback=None, timeout=5.0)` | 订阅;可选回调,每次收到通知时调用。 | +| `unsubscribe_status(status_name, module_name, timeout=5.0)` | 取消订阅。 | +| `register_async_callback(original_command, callback)` | 注册 `ASYNC_RESP` 回调。 | +| `unregister_async_callback(original_command)` | 移除异步响应回调。 | + +#### 轮询队列(回调的替代方案) + +| 属性 | 类型 | 描述 | +|---|---|---| +| `status_queue` | `Queue[StatusNotification]` | 通过轮询接收状态/中断广播。 | +| `async_response_queue` | `Queue[AsyncResponse]` | 通过轮询接收异步响应。 | + +--- + +### `AsyncTcpRouterClient`(asyncio) + +所有方法均为 `async def` 协程,需使用 `await` 调用。 + +#### 连接管理 + +| 方法 | 描述 | +|---|---| +| `await connect(host, port, timeout=5.0)` | 建立 TCP 连接;失败时抛出 `ConnectionError`。 | +| `await disconnect()` | 关闭连接;未连接时可安全调用。 | +| `connected` | 写入端开启时为 `True`。 | +| `await wait_for_server(host, port, timeout=30, retry_interval=0.5)` | 轮询直到服务器可达。 | + +#### 指令方法 + +| 方法 | 描述 | +|---|---| +| `await send_and_wait(command, timeout=5.0) → CommandResponse` | 同步指令(`-@`)。 | +| `await post(command, timeout=5.0)` | 异步指令(`->`)。 | +| `await post_no_reply(command, timeout=5.0)` | 无响应指令(`->|`)。 | +| `await ping(timeout=2.0) → (bool, float)` | 往返延迟检测。 | + +#### 路由器管理助手 + +与同步客户端相同,但所有方法均为 `async def`。 + +#### 订阅管理 + +| 方法 | 描述 | +|---|---| +| `await subscribe_status(status_name, module_name, callback=None, timeout=5.0)` | 订阅;回调可以是普通函数或 `async def` 协程。 | +| `await unsubscribe_status(status_name, module_name, timeout=5.0)` | 取消订阅。 | +| `register_async_callback(original_command, callback)` | 注册 `ASYNC_RESP` 回调;可以是普通函数或 `async def`。 | +| `unregister_async_callback(original_command)` | 移除回调。 | + +#### 轮询队列 + +| 属性 | 类型 | 描述 | +|---|---|---| +| `status_queue` | `asyncio.Queue[StatusNotification]` | `connect()` 后可用;使用 `await queue.get()` 轮询。 | +| `async_response_queue` | `asyncio.Queue[AsyncResponse]` | `connect()` 后可用。 | + +--- + +### 数据模型 + +#### `CommandResponse` +- `.raw: bytes` – 原始服务器负载 +- `.text: str` – UTF-8 解码后的文本 + +#### `AsyncResponse` +- `.raw: bytes`, `.text: str` +- `.original_command: str` – 服务器回显的原始指令 + +#### `StatusNotification` +- `.raw: bytes` +- `.packet_type: PacketType` – `STATUS` 或 `INTERRUPT` +- `.status_name: str` – 例如 `"Status"` +- `.data: str` – 广播的值 +- `.module_name: str` – 发送该状态的 CSM 模块名称 + +### 异常 + +| 异常 | 触发场景 | +|---|---| +| `TcpRouterError` | 所有 SDK 异常的基类 | +| `ConnectionError` | TCP 连接失败或断开 | +| `TimeoutError` | 在超时时间内未收到响应 | +| `ProtocolError` | 无效或意外的数据帧 | +| `ServerError` | 服务器返回 `ERROR` 包;可通过 `.code` 和 `.message` 属性获取错误详情 | + +--- + +## 示例 + +详见 [`examples/`](examples/) 目录: + +- [`basic_usage.py`](examples/basic_usage.py) – 同步客户端:连接、Ping、列出模块、发送指令。 +- [`subscribe_status.py`](examples/subscribe_status.py) – 同步客户端:通过回调实时接收状态订阅。 +- [`async_usage.py`](examples/async_usage.py) – 异步客户端:使用 `async def` / `await` 实现所有功能。 + +--- + +## 从旧版脚本 SDK 迁移 + +原有的单文件 SDK(`SDK/PythonClientAPI/tcp_router_client.py`)仍然可用,但无法通过 pip 安装,且其数据包类型编号采用的是 v1 草稿协议,而非已发布的 v0 规范。 + +| 旧方法 | 新方法 | 备注 | +|---|---|---| +| `connect()` | `connect()` | 返回 `None`;失败时抛出 `ConnectionError` 而非返回 `False` | +| `disconnect()` | `disconnect()` | 无变化 | +| `send_message_and_wait_for_reply(msg)` | `send_and_wait(cmd)` | 返回 `CommandResponse`;出错时抛出异常 | +| `post_message(msg)` | `post(cmd)` | 等待 `CMD_RESP` 握手 | +| `post_no_rep_message(msg)` | `post_no_reply(cmd)` | 等待 `CMD_RESP` 握手 | +| `ping()` | `ping()` | 签名不变 | +| `register_status_change(s, m, cb)` | `subscribe_status(s, m, callback=cb)` | 失败时抛出异常而非返回 `False` | +| `unregister_status_change(s, m)` | `unsubscribe_status(s, m)` | 失败时抛出异常 | +| `wait_for_server(h, p, t)` | `wait_for_server(h, p, timeout=t)` | 改为关键字参数 | +| `obtain()` / `release()` | 使用上下文管理器 `with TcpRouterClient() as c:` | — | + +--- + +## 开发 + +```bash +# 安装开发依赖 +pip install -e ".[dev]" +# 或 +pip install hatchling pytest pytest-asyncio ruff + +# 运行测试(同步 + 异步) +pytest + +# 代码检查 +ruff check src/ tests/ +``` + +--- + +## 许可证 + +[MIT](LICENSE) — © NEVSTOP-LAB diff --git a/SDK/python/examples/async_usage.py b/SDK/python/examples/async_usage.py new file mode 100644 index 0000000..55a6e88 --- /dev/null +++ b/SDK/python/examples/async_usage.py @@ -0,0 +1,96 @@ +"""Async quickstart example for csm-tcp-router-client. + +Run against a live CSM-TCP-Router server:: + + pip install csm-tcp-router-client + python examples/async_usage.py +""" + +import asyncio + +from csm_tcp_router_client import AsyncTcpRouterClient, StatusNotification + + +async def on_status(notif: StatusNotification) -> None: + """Async callback – invoked each time the subscribed status changes.""" + print(f"[async callback] {notif.module_name}/{notif.status_name} = {notif.data!r}") + + +async def main() -> None: + # --------------------------------------------------------------------------- + # Basic connection + # --------------------------------------------------------------------------- + async with AsyncTcpRouterClient() as client: + # Optional: wait until the server is available (e.g. during app startup) + print("Waiting for server …", end=" ", flush=True) + ok = await client.wait_for_server("localhost", 30007, timeout=15.0) + if not ok: + print("timed out") + return + print("ready") + + await client.connect("localhost", 30007) + print(f"Connected: {client.connected}") + + # --------------------------------------------------------------------------- + # Router management helpers + # --------------------------------------------------------------------------- + modules = await client.list_modules() + print(f"\nLoaded modules:\n{modules}") + + # Ping / latency check + ok, elapsed_s = await client.ping() + print(f"\nPing: {ok}, latency = {elapsed_s * 1000:.1f} ms") + + # --------------------------------------------------------------------------- + # Synchronous command (client blocks until RESP arrives) + # --------------------------------------------------------------------------- + resp = await client.send_and_wait("API: Read -@ DAQmx", timeout=5.0) + print(f"\nsend_and_wait → {resp.text!r}") + + # --------------------------------------------------------------------------- + # Asynchronous command (await the cmd-resp handshake only) + # --------------------------------------------------------------------------- + await client.post("API: Start Sampling -> DAQmx", timeout=5.0) + print("post → handshake received (async result delivered via queue)") + + # Collect the eventual async response from the queue + if client.async_response_queue is not None: + try: + ar = await asyncio.wait_for(client.async_response_queue.get(), timeout=5.0) + print(f"async_response_queue → {ar.text!r}") + except asyncio.TimeoutError: + print("async_response_queue → no result yet (server may not have replied)") + + # --------------------------------------------------------------------------- + # No-reply command + # --------------------------------------------------------------------------- + await client.post_no_reply("API: Reset ->| DAQmx", timeout=5.0) + print("post_no_reply → handshake received") + + # --------------------------------------------------------------------------- + # Status subscription with an async callback + # --------------------------------------------------------------------------- + await client.subscribe_status("Status", "DAQmx", callback=on_status, timeout=5.0) + print("\nSubscribed to Status@DAQmx — waiting 3 s for notifications …") + await asyncio.sleep(3.0) + + # Also drain any notifications that arrived via the polling queue + if client.status_queue is not None: + count = 0 + while not client.status_queue.empty(): + notif = client.status_queue.get_nowait() + print( + f" [queue poll] {notif.module_name}/{notif.status_name} = {notif.data!r}" + ) + count += 1 + print(f" {count} notification(s) retrieved from queue") + + await client.unsubscribe_status("Status", "DAQmx", timeout=5.0) + print("Unsubscribed") + + print("\nDisconnected.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/SDK/python/examples/basic_usage.py b/SDK/python/examples/basic_usage.py new file mode 100644 index 0000000..e53652e --- /dev/null +++ b/SDK/python/examples/basic_usage.py @@ -0,0 +1,99 @@ +"""Basic usage example for csm-tcp-router-client. + +Prerequisites +------------- +A running CSM-TCP-Router server (LabVIEW app). The reference app defaults +to port 30007. Start it from ``CSM-TCP-Router(Server).vi``. + +Install the SDK:: + + pip install csm-tcp-router-client + +Run this example:: + + python basic_usage.py +""" + + +from csm_tcp_router_client import ConnectionError, TcpRouterClient + +HOST = "localhost" +PORT = 30007 + + +def main() -> None: + # ----------------------------------------------------------------------- + # 1. Wait until the server is ready (optional – useful during app startup) + # ----------------------------------------------------------------------- + print("Waiting for server …", end=" ", flush=True) + client = TcpRouterClient() + ok = client.wait_for_server(HOST, PORT, timeout=30, retry_interval=0.5) + if not ok: + print("TIMEOUT – server did not start within 30 s.") + return + print("ready.") + + # ----------------------------------------------------------------------- + # 2. Connect (use as a context manager so disconnect is always called) + # ----------------------------------------------------------------------- + with TcpRouterClient() as client: + try: + client.connect(HOST, PORT) + except ConnectionError as exc: + print(f"Connection failed: {exc}") + return + + print(f"Connected to {HOST}:{PORT}") + + # ------------------------------------------------------------------- + # 3. Ping – verify round-trip latency + # ------------------------------------------------------------------- + ok, ms = client.ping() + if ok: + print(f"Ping OK latency={ms * 1000:.1f} ms") + else: + print("Ping failed.") + + # ------------------------------------------------------------------- + # 4. List CSM modules loaded on the server + # ------------------------------------------------------------------- + modules = client.list_modules() + print(f"\nLoaded modules:\n{modules}") + + # ------------------------------------------------------------------- + # 5. List the API for the first module (if any) + # ------------------------------------------------------------------- + first_module = modules.strip().splitlines()[0] if modules.strip() else None + if first_module: + api_text = client.list_api(first_module) + print(f"\nAPI for '{first_module}':\n{api_text}") + + # ------------------------------------------------------------------- + # 6. Send a synchronous command (replace with a real API of yours) + # ------------------------------------------------------------------- + # resp = client.send_and_wait("API: Read -@ DAQmx") + # print(f"\nSync response: {resp.text}") + + # ------------------------------------------------------------------- + # 7. Send an asynchronous command (server returns cmd-resp handshake) + # ------------------------------------------------------------------- + # client.post("API: Start Sampling -> DAQmx") + # print("Async command sent – waiting for async-resp …") + # time.sleep(1) + # if not client.async_response_queue.empty(): + # ar = client.async_response_queue.get_nowait() + # print(f"Async-resp: {ar.text}") + + # ------------------------------------------------------------------- + # 8. Send a no-reply command + # ------------------------------------------------------------------- + # client.post_no_reply("API: Reset ->| DAQmx") + # print("No-reply command sent.") + + print("\nDone.") + + print("Disconnected.") + + +if __name__ == "__main__": + main() diff --git a/SDK/python/examples/client_console.py b/SDK/python/examples/client_console.py new file mode 100644 index 0000000..77aa05d --- /dev/null +++ b/SDK/python/examples/client_console.py @@ -0,0 +1,222 @@ +"""Interactive client console for csm-tcp-router-client. + +A small REPL that connects to a running CSM-TCP-Router server, accepts +user-typed commands from stdin, and forwards them through the SDK. +The same command set, prompt and output format are implemented in the C +and C# SDK examples (``client_console.c`` and ``examples/ClientConsole``) +so behavior is identical across all three languages. + +Prerequisites +------------- +A running CSM-TCP-Router server (LabVIEW app) – the reference server +defaults to port 30007. Start it from ``CSM-TCP-Router(Server).vi``. + +Install the SDK:: + + pip install csm-tcp-router-client + +Run this example:: + + python client_console.py [host] [port] + +Available commands at the ``csm>`` prompt +----------------------------------------- + help Show this help text + quit / exit Disconnect and exit + ping Measure round-trip latency + list List CSM modules loaded on the server + api List the API of a module + state List the states of a module + mhelp Server-side Help for a module + send Send a synchronous command and print the response + post Send an asynchronous command (``->`` suffix) + nopost Send a no-reply asynchronous command (``->|``) + sub @ Subscribe to a status broadcast + unsub @ Unsubscribe from a status broadcast +""" + +from __future__ import annotations + +import sys + +from csm_tcp_router_client import ( + AsyncResponse, + ConnectionError, + ServerError, + StatusNotification, + TcpRouterClient, + TcpRouterError, +) + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 30007 + +HELP_TEXT = """\ +Available commands: + help Show this help text + quit / exit Disconnect and exit + ping Measure round-trip latency + list List CSM modules loaded on the server + api List the API of a module + state List the states of a module + mhelp Server-side Help for a module + send Send a synchronous command and print the response + post Send an asynchronous command (-> suffix) + nopost Send a no-reply asynchronous command (->|) + sub @ Subscribe to a status broadcast + unsub @ Unsubscribe from a status broadcast""" + + +def _on_status(notification: StatusNotification) -> None: + """Print every status broadcast received on a subscription.""" + print( + f"\n[STATUS] {notification.status_name}@{notification.module_name}" + f": {notification.data}" + ) + + +def _on_async(response: AsyncResponse) -> None: + """Print every async-resp packet that matches a registered command.""" + print(f"\n[ASYNC] {response.text} (cmd={response.original_command})") + + +def _split_status_module(arg: str) -> tuple[str, str]: + """Parse ``@`` into a ``(status, module)`` tuple.""" + if "@" not in arg: + raise ValueError("expected '@'") + status, module = arg.split("@", 1) + status, module = status.strip(), module.strip() + if not status or not module: + raise ValueError("expected '@'") + return status, module + + +def _dispatch(client: TcpRouterClient, line: str) -> bool: + """Execute one user line. Return False to exit the REPL.""" + line = line.strip() + if not line: + return True + + parts = line.split(None, 1) + cmd = parts[0].lower() + arg = parts[1].strip() if len(parts) == 2 else "" + + if cmd in ("quit", "exit"): + return False + if cmd == "help": + print(HELP_TEXT) + return True + if cmd == "ping": + ok, elapsed = client.ping() + if ok: + print(f"Ping OK latency={elapsed * 1000:.1f} ms") + else: + print("Ping failed.") + return True + if cmd == "list": + print(client.list_modules()) + return True + if cmd == "api": + if not arg: + print("Error: usage: api ") + else: + print(client.list_api(arg)) + return True + if cmd == "state": + if not arg: + print("Error: usage: state ") + else: + print(client.list_states(arg)) + return True + if cmd == "mhelp": + if not arg: + print("Error: usage: mhelp ") + else: + print(client.help(arg)) + return True + if cmd == "send": + if not arg: + print("Error: usage: send ") + else: + resp = client.send_and_wait(arg) + print(f"Response: {resp.text}") + return True + if cmd == "post": + if not arg: + print("Error: usage: post ") + else: + client.register_async_callback(arg, _on_async) + client.post(arg) + print("Async command sent.") + return True + if cmd == "nopost": + if not arg: + print("Error: usage: nopost ") + else: + client.post_no_reply(arg) + print("No-reply command sent.") + return True + if cmd == "sub": + status, module = _split_status_module(arg) + client.subscribe_status(status, module, callback=_on_status) + print(f"Subscribed to {status}@{module}") + return True + if cmd == "unsub": + status, module = _split_status_module(arg) + client.unsubscribe_status(status, module) + print(f"Unsubscribed from {status}@{module}") + return True + + print(f"Error: unknown command '{cmd}'. Type 'help' for the command list.") + return True + + +def main(argv: list[str]) -> int: + host = argv[1] if len(argv) > 1 else DEFAULT_HOST + if len(argv) > 2: + try: + port = int(argv[2]) + except ValueError: + print(f"Error: invalid port '{argv[2]}'") + return 1 + if port < 1 or port > 65535: + print(f"Error: invalid port '{argv[2]}'") + return 1 + else: + port = DEFAULT_PORT + + print("CSM-TCP-Router Client Console") + print(f"Connecting to {host}:{port} ...") + + client = TcpRouterClient() + try: + client.connect(host, port) + except ConnectionError as exc: + print(f"Error: {exc}") + return 1 + + print(f"Connected to {host}:{port}. Type 'help' for commands, 'quit' to exit.") + try: + while True: + try: + line = input("csm> ") + except (EOFError, KeyboardInterrupt): + print() + break + try: + if not _dispatch(client, line): + break + except ServerError as exc: + print(f"Error: {exc}") + except TcpRouterError as exc: + print(f"Error: {exc}") + except ValueError as exc: + print(f"Error: {exc}") + finally: + client.disconnect() + print("Disconnected.") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/SDK/python/examples/subscribe_status.py b/SDK/python/examples/subscribe_status.py new file mode 100644 index 0000000..165df6b --- /dev/null +++ b/SDK/python/examples/subscribe_status.py @@ -0,0 +1,85 @@ +"""Status subscription example for csm-tcp-router-client. + +Prerequisites +------------- +A running CSM-TCP-Router server that has a CSM module publishing a status. +The reference app (``CSM-TCP-Router(Server).vi``) exposes an ``AI`` module +that continuously broadcasts a ``Status`` status. + +Install the SDK:: + + pip install csm-tcp-router-client + +Run this example:: + + python subscribe_status.py +""" + +import signal +import threading +import time + +from csm_tcp_router_client import ConnectionError, ServerError, StatusNotification, TcpRouterClient + +HOST = "localhost" +PORT = 30007 + +# Module and status name to subscribe to (adjust to match your server) +MODULE_NAME = "AI" +STATUS_NAME = "Status" + +# Global stop flag +_stop = threading.Event() + + +def on_status(notification: StatusNotification) -> None: + """Callback invoked on every status broadcast from the server.""" + print( + f"[{time.strftime('%H:%M:%S')}] " + f"{notification.status_name} @ {notification.module_name} " + f"→ {notification.data}" + ) + + +def main() -> None: + # Allow Ctrl-C to exit cleanly + signal.signal(signal.SIGINT, lambda *_: _stop.set()) + + with TcpRouterClient() as client: + try: + client.connect(HOST, PORT) + except ConnectionError as exc: + print(f"Connection failed: {exc}") + return + + print(f"Connected to {HOST}:{PORT}") + + # Subscribe to status broadcasts + try: + client.subscribe_status(STATUS_NAME, MODULE_NAME, callback=on_status) + print( + f"Subscribed to '{STATUS_NAME}' from module '{MODULE_NAME}'. " + "Press Ctrl-C to exit.\n" + ) + except ServerError as exc: + print(f"Subscription failed: {exc}") + return + + # Keep running until Ctrl-C + while not _stop.is_set(): + # You can also poll client.status_queue here if you prefer + # notification = client.status_queue.get(timeout=1.0) + time.sleep(0.1) + + # Unsubscribe cleanly before disconnecting + try: + client.unsubscribe_status(STATUS_NAME, MODULE_NAME) + print("\nUnsubscribed.") + except Exception: + pass + + print("Disconnected.") + + +if __name__ == "__main__": + main() diff --git a/SDK/python/pyproject.toml b/SDK/python/pyproject.toml new file mode 100644 index 0000000..90ec5bb --- /dev/null +++ b/SDK/python/pyproject.toml @@ -0,0 +1,86 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "csm-tcp-router-client" +version = "0.3.0" +description = "Python client SDK for the CSM-TCP-Router LabVIEW server" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.8" +authors = [{ name = "NEVSTOP-LAB" }] +keywords = [ + "csm", + "labview", + "tcp", + "router", + "client", + "sdk", + "daq", + "communicable-state-machine", + "asyncio", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: AsyncIO", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Networking", +] + +[project.urls] +Homepage = "https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App" +Repository = "https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App" +Issues = "https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/issues" +Documentation = "https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/blob/main/SDK/python/README.md" +Changelog = "https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/blob/main/SDK/python/CHANGELOG.md" + +[tool.hatch.build.targets.wheel] +only-include = ["src/csm_tcp_router_client.py"] +sources = ["src"] + +# --------------------------------------------------------------------------- +# Testing +# --------------------------------------------------------------------------- +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --tb=short" +asyncio_mode = "auto" + +# --------------------------------------------------------------------------- +# Linting (ruff) +# --------------------------------------------------------------------------- +[tool.ruff] +target-version = "py38" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "C4", "PIE", "SIM", "RUF"] +ignore = [ + "E501", # line length handled by formatter + "B008", # do not perform function calls in argument defaults + "UP006", # use `type` instead of `Type` — Python 3.8 compat + "UP007", # use `X | Y` — Python 3.9 compat + "UP035", # deprecated typing imports — Python 3.8 compat + "UP045", # use `X | None` — Python 3.10 compat + "SIM105", # contextlib.suppress — prefer explicit try/except for clarity + "RUF001", # ambiguous unicode in strings — intentional em-dash usage + "RUF002", # ambiguous unicode in docstrings — intentional em-dash usage + "RUF003", # ambiguous unicode in comments — intentional em-dash usage + "RUF022", # __all__ not sorted — grouped by category intentionally +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101"] # allow assert in tests +"examples/*" = ["T201"] # allow print in examples diff --git a/SDK/python/src/csm_tcp_router_client.py b/SDK/python/src/csm_tcp_router_client.py new file mode 100644 index 0000000..f359f3a --- /dev/null +++ b/SDK/python/src/csm_tcp_router_client.py @@ -0,0 +1,1303 @@ +"""csm-tcp-router-client – CSM-TCP-Router 服务器的单文件 Python 客户端 SDK。 + +本模块将完整的客户端实现(同步与异步)、线路协议编解码器、 +异常层次结构以及公共数据模型打包到一个可直接导入的文件中。 + +同步用法:: + + from csm_tcp_router_client import TcpRouterClient + + with TcpRouterClient() as client: + client.connect("localhost", 30007) + print(client.list_modules()) + +异步用法:: + + import asyncio + from csm_tcp_router_client import AsyncTcpRouterClient + + async def main(): + async with AsyncTcpRouterClient() as client: + await client.connect("localhost", 30007) + print(await client.list_modules()) + + asyncio.run(main()) + +线路格式(8 字节报头,大端序):: + + | Data Length (4B) | Version (1B=0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | + ╰────────────────────────── Header (8B) ──────────────────────────╯ + +后跟恰好 ``Data Length`` 字节的有效载荷。 +""" + +from __future__ import annotations + +import asyncio +import inspect +import queue +import socket +import struct +import threading +import time +from dataclasses import dataclass +from enum import IntEnum +from typing import Any, Callable, Coroutine, Dict, Optional, Tuple, Union + +__all__ = [ + # 客户端 + "TcpRouterClient", + "AsyncTcpRouterClient", + # 异常 + "TcpRouterError", + "ConnectionError", + "TimeoutError", + "ProtocolError", + "ServerError", + # 数据模型 + "PacketType", + "Packet", + "CommandResponse", + "AsyncResponse", + "StatusNotification", + # 版本 + "__version__", +] + +__version__ = "0.3.0" + + +# =========================================================================== +# 异常 +# =========================================================================== + + +class TcpRouterError(Exception): + """所有 CSM-TCP-Router 客户端错误的基异常。""" + + +class ConnectionError(TcpRouterError): + """当连接无法建立或已断开时抛出。""" + + +class TimeoutError(TcpRouterError): + """当同步操作超过其超时时间时抛出。""" + + +class ProtocolError(TcpRouterError): + """当接收到无效或意外的协议帧时抛出。""" + + +class ServerError(TcpRouterError): + """当服务器返回错误数据包时抛出。 + + Attributes: + message: 来自服务器的可读错误文本。 + code: 从 CSM 错误格式 ``[Error: ] `` 中提取的可选错误代码。 + """ + + def __init__(self, message: str, code: str = "") -> None: + super().__init__(message) + self.message = message + self.code = code + + def __str__(self) -> str: + if self.code: + return f"[Error: {self.code}] {self.message}" + return self.message + + +# 传输/接收代码中使用的内部别名,用于避免与模块级遮蔽的内置名称产生歧义。 +_RouterConnectionError = ConnectionError +_RouterTimeoutError = TimeoutError + + +# =========================================================================== +# 公共数据模型 +# =========================================================================== + + +class PacketType(IntEnum): + """CSM-TCP-Router 协议 v0 中定义的数据包类型常量。 + + 线路值 + ----------- + ``INFO`` 0x00 – 信息消息(欢迎/再见) + ``ERROR`` 0x01 – 来自服务器的错误消息 + ``CMD`` 0x02 – 客户端发送的命令 + ``CMD_RESP`` 0x03 – 服务器对异步/无回复/订阅的握手确认 + ``RESP`` 0x04 – 同步响应有效载荷 + ``ASYNC_RESP`` 0x05 – 异步响应有效载荷 + ``STATUS`` 0x06 – 来自已订阅 CSM 模块的状态广播 + ``INTERRUPT`` 0x07 – 来自已订阅 CSM 模块的中断广播 + """ + + INFO = 0x00 + ERROR = 0x01 + CMD = 0x02 + CMD_RESP = 0x03 + RESP = 0x04 + ASYNC_RESP = 0x05 + STATUS = 0x06 + INTERRUPT = 0x07 + + +@dataclass(frozen=True) +class Packet: + """从服务器接收到的已解码数据包(内部表示)。""" + + type: PacketType + data: bytes + version: int = 1 + flag1: int = 0 + flag2: int = 0 + + +@dataclass(frozen=True) +class CommandResponse: + """同步命令(:meth:`TcpRouterClient.send_and_wait`)的结果。""" + + raw: bytes + + @property + def text(self) -> str: + """响应有效载荷的 UTF-8 解码文本。""" + return self.raw.decode("utf-8", errors="replace") + + def __repr__(self) -> str: + return f"CommandResponse({self.text!r})" + + +@dataclass(frozen=True) +class AsyncResponse: + """通过 ``async-resp`` 数据包传递的异步响应有效载荷。 + + Attributes: + raw: 原始响应字节(`` <- `` 分隔符*之前*的部分)。 + original_command: 服务器回显的原始命令文本 + (`` <- `` 分隔符*之后*的部分)。 + """ + + raw: bytes + original_command: str = "" + + @property + def text(self) -> str: + """响应有效载荷的 UTF-8 解码文本。""" + return self.raw.decode("utf-8", errors="replace") + + @classmethod + def from_packet(cls, packet: Packet) -> AsyncResponse: + """解析一个 ``ASYNC_RESP`` 数据包。 + + 服务器格式:``" <- "``。 + """ + text = packet.data.decode("utf-8", errors="replace") + parts = text.split(" <- ", 1) + if len(parts) == 2: + return cls(raw=parts[0].encode("utf-8"), original_command=parts[1]) + return cls(raw=packet.data) + + def __repr__(self) -> str: + return f"AsyncResponse({self.text!r}, cmd={self.original_command!r})" + + +@dataclass(frozen=True) +class StatusNotification: + """通过 ``status`` 或 ``interrupt`` 数据包传递的状态广播。 + + Attributes: + raw: 完整的原始有效载荷字节。 + packet_type: :attr:`PacketType.STATUS` 或 + :attr:`PacketType.INTERRUPT` 之一。 + status_name: 广播的状态名称(``>>`` 左侧)。 + data: 状态有效载荷(``>>`` 与 ``<-`` 之间)。 + module_name: 发送该状态的 CSM 模块名称(``<-`` 右侧)。 + """ + + raw: bytes + packet_type: PacketType = PacketType.STATUS + status_name: str = "" + data: str = "" + module_name: str = "" + + @classmethod + def from_packet(cls, packet: Packet) -> StatusNotification: + """解析一个 ``STATUS`` 或 ``INTERRUPT`` 数据包。 + + 服务器格式:``" >> <- "``。 + """ + text = packet.data.decode("utf-8", errors="replace") + module = "" + left = text + if " <- " in text: + left, module = text.rsplit(" <- ", 1) + module = module.strip() + status_name = "" + data = left.strip() + if " >> " in left: + status_name, data = left.split(" >> ", 1) + status_name = status_name.strip() + data = data.strip() + return cls( + raw=packet.data, + packet_type=packet.type, + status_name=status_name, + data=data, + module_name=module, + ) + + def __repr__(self) -> str: + return ( + f"StatusNotification(status={self.status_name!r}, " + f"data={self.data!r}, module={self.module_name!r})" + ) + + +# =========================================================================== +# 协议编解码器(内部使用,但可导入供高级用途/测试) +# =========================================================================== + +# 报头布局:大端序 uint32 data_len + 4 x uint8(version, type, flag1, flag2) +_HEADER_FORMAT = "!IBBBB" + +#: 固定数据包报头的字节数。 +HEADER_SIZE: int = struct.calcsize(_HEADER_FORMAT) # == 8 + +#: 每个出站数据包中发送的协议版本字节。 +PROTOCOL_VERSION: int = 0x01 + + +def encode_packet( + data: bytes, + packet_type: PacketType, + flag1: int = 0, + flag2: int = 0, +) -> bytes: + """将 *data* 编码为完整的线路格式数据包(报头 + 正文)。 + + :param data: 原始有效载荷字节。 + :param packet_type: 报头中使用的 :class:`PacketType`。 + :param flag1: FLAG1 字节(当前未使用;默认为 0)。 + :param flag2: FLAG2 字节(当前未使用;默认为 0)。 + :returns: 已拼接的报头 + 有效载荷字节,可直接传递给 ``sendall()``。 + """ + header = struct.pack( + _HEADER_FORMAT, + len(data), + PROTOCOL_VERSION, + packet_type.value, + flag1, + flag2, + ) + return header + data + + +def decode_header(header_bytes: bytes) -> Tuple[int, int, int, int, int]: + """将 8 字节报头解码为其各组成字段。 + + :returns: ``(data_len, version, type_byte, flag1, flag2)`` + :raises ProtocolError: 若 *header_bytes* 不恰好为 :data:`HEADER_SIZE` 字节。 + """ + if len(header_bytes) != HEADER_SIZE: + raise ProtocolError( + f"Expected {HEADER_SIZE}-byte header, got {len(header_bytes)} bytes." + ) + return struct.unpack(_HEADER_FORMAT, header_bytes) # type: ignore[return-value] + + +def parse_packet(header_bytes: bytes, body: bytes) -> Packet: + """从原始报头 + 正文构建 :class:`Packet`。 + + 未知的数据包类型字节将映射到 :attr:`PacketType.INFO` 以保持 + 前向兼容性(服务器在未来的协议修订中可能引入新类型)。 + + :raises ProtocolError: 当报头大小不匹配或正文长度不匹配时。 + """ + data_len, version, type_byte, flag1, flag2 = decode_header(header_bytes) + if len(body) != data_len: + raise ProtocolError( + f"Payload length mismatch: header says {data_len} bytes, " + f"got {len(body)} bytes." + ) + try: + ptype = PacketType(type_byte) + except ValueError: + # 前向兼容:将未知类型视为 INFO + ptype = PacketType.INFO + return Packet(type=ptype, data=body, version=version, flag1=flag1, flag2=flag2) + + +# =========================================================================== +# 共享的服务器错误解析辅助函数 +# =========================================================================== + + +def _parse_server_error(packet: Packet) -> ServerError: + """从 CSM 错误格式 ``[Error: ] `` 中提取错误代码和消息。""" + text = packet.data.decode("utf-8", errors="replace").strip() + code = "" + msg = text + if text.startswith("[Error:"): + try: + end_idx = text.index("]") + code = text[7:end_idx].strip() + msg = text[end_idx + 1 :].strip() + except ValueError: + pass + return ServerError(msg, code) + + +# =========================================================================== +# 内部:基于线程的 TCP 传输(由同步客户端使用) +# =========================================================================== + + +class _Transport: + """线程安全的阻塞式 TCP 传输。 + + 后台守护线程持续从套接字读取数据包,并通过 *on_packet* 进行分发。 + 调用方负责保持回调函数快速且无阻塞,因为它们在接收线程中运行。 + """ + + def __init__( + self, + on_packet: Callable[[Packet], None], + on_disconnect: Callable[[], None], + ) -> None: + self._sock: Optional[socket.socket] = None + self._send_lock = threading.Lock() + self._stop_event = threading.Event() + self._recv_thread: Optional[threading.Thread] = None + self._on_packet = on_packet + self._on_disconnect = on_disconnect + + @property + def connected(self) -> bool: + """``True`` 表示套接字已打开且停止事件尚未触发。""" + return self._sock is not None and not self._stop_event.is_set() + + def connect(self, host: str, port: int, timeout: float = 5.0) -> None: + """建立 TCP 连接并启动接收线程。""" + if self.connected: + raise _RouterConnectionError( + "Already connected; call disconnect() first." + ) + sock: Optional[socket.socket] = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((host, port)) + sock.settimeout(None) # 切换为阻塞模式以用于接收循环 + except OSError as exc: + if sock is not None: + try: + sock.close() + except OSError: + pass + raise _RouterConnectionError( + f"Cannot connect to {host}:{port}: {exc}" + ) from exc + + self._sock = sock + self._stop_event.clear() + self._recv_thread = threading.Thread( + target=self._recv_loop, + daemon=True, + name="csm-tcp-router-recv", + ) + self._recv_thread.start() + + def disconnect(self, join_timeout: float = 2.0) -> None: + """关闭连接并停止接收线程。""" + self._stop_event.set() + if self._sock is not None: + try: + self._sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + self._sock.close() + except OSError: + pass + self._sock = None + if self._recv_thread is not None and self._recv_thread.is_alive(): + self._recv_thread.join(timeout=join_timeout) + + def send_raw(self, data: bytes) -> None: + """原子性地发送 *data*。线程安全。""" + if not self.connected: + raise _RouterConnectionError("Not connected.") + with self._send_lock: + try: + self._sock.sendall(data) # type: ignore[union-attr] + except OSError as exc: + self._stop_event.set() + raise _RouterConnectionError(f"Send failed: {exc}") from exc + + def _recv_all(self, size: int) -> bytes: + """精确读取 *size* 字节;在干净的 EOF 或断开连接时返回空字节。""" + buf = bytearray(size) + view = memoryview(buf) + received = 0 + while received < size: + sock = self._sock # 本地捕获,避免与 disconnect() 产生 TOCTOU 竞争 + if sock is None: + return b"" + try: + n = sock.recv_into(view[received:], size - received) + except OSError: + return b"" + if n == 0: + return b"" + received += n + return bytes(buf) + + def _recv_loop(self) -> None: + """后台线程:读取数据包并通过回调进行分发。""" + try: + while not self._stop_event.is_set(): + header = self._recv_all(HEADER_SIZE) + if not header: + break + + # 从前 4 个字节提取 data_len,无需完整解码 + (data_len,) = struct.unpack("!I", header[:4]) + body = self._recv_all(data_len) + if len(body) != data_len: + break + + try: + packet = parse_packet(header, body) + except ProtocolError: + # 帧损坏 – 跳过并保持循环运行 + continue + + self._on_packet(packet) + + except OSError: + pass + finally: + if not self._stop_event.is_set(): + self._stop_event.set() + self._on_disconnect() + + +# =========================================================================== +# TcpRouterClient – 基于线程的同步客户端 +# =========================================================================== + +# 类型别名 +_SubKey = Tuple[str, str] +StatusCallback = Callable[[StatusNotification], None] +AsyncCallback = Callable[[AsyncResponse], None] + +# 内部队列中存放的元素为 Packet 或 Exception 实例。 +_QueueItem = object + + +class TcpRouterClient: + """CSM-TCP-Router 服务器的同步客户端。 + + 本类镜像了 LabVIEW ClientAPI VI,并实现了 CSM-TCP-Router 协议 v0。 + 其内部状态通过锁保护,因此是线程安全的;但协议同时只允许一个在途 + *同步* 命令和一个在途 *异步* 命令/订阅。并发调用者分别由 + ``_resp_lock`` 和 ``_cmd_resp_lock`` 串行化。 + + **快速入门**:: + + from csm_tcp_router_client import TcpRouterClient + + with TcpRouterClient() as client: + client.connect("localhost", 30007) + print(client.list_modules()) + + **协议流程**: + + - *同步* 命令 (``-@``)::meth:`send_and_wait` – 发送 ``CMD`` 包并阻塞 + 直到收到 ``RESP``(或 ``ERROR``)。 + - *异步* 命令 (``->``)::meth:`post` – 发送 ``CMD`` 包并阻塞直到收到 + ``CMD_RESP`` 握手;最终的 ``ASYNC_RESP`` 会异步投递。 + - *无回复异步* 命令 (``->|``)::meth:`post_no_reply` – 与 + :meth:`post` 相同,但不会有 ``ASYNC_RESP`` 到来。 + - *订阅 / 取消订阅*::meth:`subscribe_status` / + :meth:`unsubscribe_status` – 发送 ```` / ```` + 命令并等待 ``CMD_RESP`` 握手。 + + **接收包路由**(在后台接收线程上): + + - ``RESP`` (0x04) – 解除 :meth:`send_and_wait` 调用者的阻塞。 + - ``CMD_RESP`` (0x03) – 解除 :meth:`post`、:meth:`post_no_reply`、 + :meth:`subscribe_status` 和 :meth:`unsubscribe_status` 调用者的阻塞。 + - ``ASYNC_RESP`` (0x05) – 加入 :attr:`async_response_queue` 并 + 分发给匹配的 :meth:`register_async_callback`。 + - ``STATUS`` / ``INTERRUPT`` (0x06 / 0x07) – 加入 :attr:`status_queue` + 并分发给匹配的 :meth:`subscribe_status` 回调。 + - ``ERROR`` (0x01) – 以 :exc:`ServerError` 解除任何待处理的同步等待者。 + - ``INFO`` (0x00) – 静默丢弃(欢迎 / 再见消息)。 + """ + + def __init__(self) -> None: + self._transport = _Transport( + on_packet=self._on_packet, + on_disconnect=self._on_disconnect, + ) + + # 用于同步等待的单项队列。 + # 队列元素为 Packet 或 Exception 实例。 + self._resp_queue: queue.Queue[_QueueItem] = queue.Queue() + self._cmd_resp_queue: queue.Queue[_QueueItem] = queue.Queue() + + #: 用于轮询从服务器收到的 :class:`AsyncResponse` 对象的队列。 + self.async_response_queue: queue.Queue[AsyncResponse] = queue.Queue() + + #: 用于轮询从服务器收到的 :class:`StatusNotification` 对象的队列。 + self.status_queue: queue.Queue[StatusNotification] = queue.Queue() + + # 回调注册表(由 _lock 保护) + self._status_callbacks: Dict[_SubKey, Optional[StatusCallback]] = {} + self._async_callbacks: Dict[str, AsyncCallback] = {} + self._lock = threading.Lock() + + # 串行化锁 – 同时最多只有一个在途的 RESP / CMD_RESP 等待者, + # 防止并发调用者消费彼此的响应包。 + self._resp_lock = threading.Lock() + self._cmd_resp_lock = threading.Lock() + + # ------------------------------------------------------------------ + # Connection management + # ------------------------------------------------------------------ + + def connect(self, host: str, port: int, timeout: float = 5.0) -> None: + """Connect to a CSM-TCP-Router server. + + :param host: Server hostname or IP address. + :param port: Server TCP port (the reference app defaults to 30007). + :param timeout: Connect timeout in seconds. + :raises ConnectionError: if the connection cannot be established. + """ + self._transport.connect(host, port, timeout=timeout) + + def disconnect(self) -> None: + """Disconnect from the server and release all resources. + + Safe to call even if not currently connected. Any threads currently + blocked inside :meth:`send_and_wait`, :meth:`post`, or similar methods + will receive a :exc:`ConnectionError` immediately rather than + waiting for their timeout to expire. + """ + # Wake blocked waiters *before* tearing down the transport. + sentinel = _RouterConnectionError("Disconnected from server.") + self._resp_queue.put(sentinel) + self._cmd_resp_queue.put(sentinel) + self._transport.disconnect() + + @property + def connected(self) -> bool: + """``True`` if the underlying transport is currently connected.""" + return self._transport.connected + + def wait_for_server( + self, + host: str, + port: int, + timeout: float = 30.0, + retry_interval: float = 0.5, + ) -> bool: + """Poll until *host*:*port* accepts a connection or *timeout* elapses. + + :returns: ``True`` when the server is reachable; ``False`` on timeout. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + probe = _Transport( + on_packet=lambda _p: None, + on_disconnect=lambda: None, + ) + try: + probe.connect(host, port, timeout=1.0) + probe.disconnect() + return True + except _RouterConnectionError: + pass + time.sleep(retry_interval) + return False + + # ------------------------------------------------------------------ + # Core command methods + # ------------------------------------------------------------------ + + def send_and_wait(self, command: str, timeout: float = 5.0) -> CommandResponse: + """Send a **synchronous** command and block until the response arrives. + + Use the CSM synchronous message suffix ``-@`` in *command*:: + + resp = client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + + The built-in router management commands (``List``, ``Ping``, …) are + also synchronous and do not require a suffix. + + :raises ConnectionError: if not connected. + :raises TimeoutError: if no response arrives within *timeout*. + :raises ServerError: if the server returns an error packet. + """ + wire = encode_packet(command.encode("utf-8"), PacketType.CMD) + with self._resp_lock: + self._transport.send_raw(wire) + return self._wait_for_resp(timeout) + + def post(self, command: str, timeout: float = 5.0) -> None: + """Send an **asynchronous** command and wait for the ``cmd-resp`` handshake. + + Use the CSM async message suffix ``->`` in *command*:: + + client.post("API: Start Sampling -> DAQmx") + + The eventual ``async-resp`` payload will be delivered to any callback + registered with :meth:`register_async_callback` and added to + :attr:`async_response_queue`. + + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the command. + """ + wire = encode_packet(command.encode("utf-8"), PacketType.CMD) + with self._cmd_resp_lock: + self._transport.send_raw(wire) + self._wait_for_cmd_resp(timeout) + + def post_no_reply(self, command: str, timeout: float = 5.0) -> None: + """Send an **async no-reply** command and wait for the ``cmd-resp`` handshake. + + Use the CSM no-reply suffix ``->|`` in *command*:: + + client.post_no_reply("API: Reset ->| DAQmx") + + After the handshake the server will not send any further response. + """ + wire = encode_packet(command.encode("utf-8"), PacketType.CMD) + with self._cmd_resp_lock: + self._transport.send_raw(wire) + self._wait_for_cmd_resp(timeout) + + def ping(self, timeout: float = 2.0) -> Tuple[bool, float]: + """Send a ``Ping`` command and measure round-trip latency. + + :returns: ``(True, elapsed_seconds)`` on success, + ``(False, 0.0)`` on failure or error. + """ + try: + t0 = time.monotonic() + self.send_and_wait("Ping", timeout=timeout) + return True, time.monotonic() - t0 + except (_RouterConnectionError, _RouterTimeoutError, ServerError): + return False, 0.0 + + # ------------------------------------------------------------------ + # Router management helpers + # ------------------------------------------------------------------ + + def list_modules(self, timeout: float = 5.0) -> str: + """Return the server's loaded CSM module list as plain text.""" + return self.send_and_wait("List", timeout=timeout).text + + def list_api(self, module: str, timeout: float = 5.0) -> str: + """Return the API list for *module* as plain text.""" + return self.send_and_wait(f"List API {module}", timeout=timeout).text + + def list_states(self, module: str, timeout: float = 5.0) -> str: + """Return the CSM state list for *module* as plain text.""" + return self.send_and_wait(f"List State {module}", timeout=timeout).text + + def help(self, module: str, timeout: float = 5.0) -> str: + """Return the help text for *module* as plain text.""" + return self.send_and_wait(f"Help {module}", timeout=timeout).text + + # ------------------------------------------------------------------ + # Status / interrupt subscriptions + # ------------------------------------------------------------------ + + def subscribe_status( + self, + status_name: str, + module_name: str, + callback: Optional[StatusCallback] = None, + timeout: float = 5.0, + ) -> None: + """Subscribe to a CSM module's status broadcast. + + Sends ``"@ ->"`` and waits for + the ``cmd-resp`` handshake. Once subscribed, + :class:`StatusNotification` objects will be: + + * delivered to *callback* (if provided), and + * added to :attr:`status_queue`. + + :param callback: Optional callable invoked on each notification. + Must be fast and non-blocking (runs in the recv thread). + """ + # Register the callback *before* sending to eliminate the race where + # a STATUS packet could arrive before the callback is stored. + with self._lock: + self._status_callbacks[(status_name, module_name)] = callback + cmd = f"{status_name}@{module_name} ->" + wire = encode_packet(cmd.encode("utf-8"), PacketType.CMD) + try: + with self._cmd_resp_lock: + self._transport.send_raw(wire) + self._wait_for_cmd_resp(timeout) + except Exception: + with self._lock: + self._status_callbacks.pop((status_name, module_name), None) + raise + + def unsubscribe_status( + self, + status_name: str, + module_name: str, + timeout: float = 5.0, + ) -> None: + """Cancel a status subscription.""" + cmd = f"{status_name}@{module_name} ->" + wire = encode_packet(cmd.encode("utf-8"), PacketType.CMD) + with self._cmd_resp_lock: + self._transport.send_raw(wire) + self._wait_for_cmd_resp(timeout) + with self._lock: + self._status_callbacks.pop((status_name, module_name), None) + + def register_async_callback( + self, + original_command: str, + callback: AsyncCallback, + ) -> None: + """Register a callback for ``async-resp`` packets. + + The callback is matched by *original_command* (the command text + echoed in the ``async-resp`` payload after the `` <- `` separator). + """ + with self._lock: + self._async_callbacks[original_command] = callback + + def unregister_async_callback(self, original_command: str) -> None: + """Remove a previously registered async callback.""" + with self._lock: + self._async_callbacks.pop(original_command, None) + + # ------------------------------------------------------------------ + # Context-manager support + # ------------------------------------------------------------------ + + def __enter__(self) -> TcpRouterClient: + return self + + def __exit__(self, *_args: object) -> None: + self.disconnect() + + # ------------------------------------------------------------------ + # Internal: packet dispatch (runs in the receive thread) + # ------------------------------------------------------------------ + + def _on_packet(self, packet: Packet) -> None: + ptype = packet.type + if ptype == PacketType.RESP: + self._resp_queue.put(packet) + + elif ptype == PacketType.CMD_RESP: + self._cmd_resp_queue.put(packet) + + elif ptype == PacketType.ASYNC_RESP: + resp = AsyncResponse.from_packet(packet) + self.async_response_queue.put(resp) + with self._lock: + cb = self._async_callbacks.get(resp.original_command) + if cb is not None: + try: + cb(resp) + except Exception: + pass + + elif ptype in (PacketType.STATUS, PacketType.INTERRUPT): + notif = StatusNotification.from_packet(packet) + self.status_queue.put(notif) + with self._lock: + cb = self._status_callbacks.get( # type: ignore[assignment] + (notif.status_name, notif.module_name) + ) + if cb is not None: + try: + cb(notif) # type: ignore[call-arg] + except Exception: + pass + + elif ptype == PacketType.ERROR: + err = _parse_server_error(packet) + # Unblock any pending synchronous waiter + self._resp_queue.put(err) + self._cmd_resp_queue.put(err) + + # PacketType.INFO is silently discarded (welcome / goodbye messages) + + def _on_disconnect(self) -> None: + """Called from the receive thread when the connection drops unexpectedly.""" + sentinel = _RouterConnectionError("Connection lost unexpectedly.") + self._resp_queue.put(sentinel) + self._cmd_resp_queue.put(sentinel) + + # ------------------------------------------------------------------ + # Internal: synchronised waiters + # ------------------------------------------------------------------ + + def _wait_for_resp(self, timeout: float) -> CommandResponse: + try: + item = self._resp_queue.get(timeout=timeout) + except queue.Empty: + raise _RouterTimeoutError( + f"No response received within {timeout:.1f}s." + ) from None + if isinstance(item, Exception): + raise item + assert isinstance(item, Packet) + return CommandResponse(raw=item.data) + + def _wait_for_cmd_resp(self, timeout: float) -> None: + try: + item = self._cmd_resp_queue.get(timeout=timeout) + except queue.Empty: + raise _RouterTimeoutError( + f"No cmd-resp received within {timeout:.1f}s." + ) from None + if isinstance(item, Exception): + raise item + # CMD_RESP payload is a handshake acknowledgment; discard it + + +# =========================================================================== +# AsyncTcpRouterClient – asyncio-based client +# =========================================================================== + +# Async callback type aliases – both plain callables and coroutines accepted +_SyncStatusCb = Callable[[StatusNotification], None] +_AsyncStatusCb = Callable[[StatusNotification], "Coroutine[Any, Any, None]"] +AsyncStatusCallback = Union[_SyncStatusCb, _AsyncStatusCb] + +_SyncAsyncRespCb = Callable[[AsyncResponse], None] +_AsyncAsyncRespCb = Callable[[AsyncResponse], "Coroutine[Any, Any, None]"] +AsyncRespCallback = Union[_SyncAsyncRespCb, _AsyncAsyncRespCb] + + +class AsyncTcpRouterClient: + """Asyncio client for a CSM-TCP-Router server. + + Provides the same interface as :class:`TcpRouterClient` but as + ``async def`` coroutines, suitable for use inside an asyncio event loop. + + **Quickstart**:: + + import asyncio + from csm_tcp_router_client import AsyncTcpRouterClient + + async def main(): + async with AsyncTcpRouterClient() as client: + await client.connect("localhost", 30007) + print(await client.list_modules()) + resp = await client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + + asyncio.run(main()) + + **Protocol flows** are identical to :class:`TcpRouterClient`. + + **Callbacks** passed to :meth:`subscribe_status` and + :meth:`register_async_callback` may be either a plain callable *or* an + ``async def`` coroutine — both are supported. + + **Polling queues** (:attr:`async_response_queue`, :attr:`status_queue`) are + created when :meth:`connect` is called and are bound to the running event + loop. Access them only after :meth:`connect` has been awaited. + """ + + def __init__(self) -> None: + self._reader: Optional[asyncio.StreamReader] = None + self._writer: Optional[asyncio.StreamWriter] = None + self._recv_task: Optional[asyncio.Task[None]] = None + + # Asyncio objects created lazily in connect() to bind to the running loop + self._resp_queue: Optional[asyncio.Queue[object]] = None + self._cmd_resp_queue: Optional[asyncio.Queue[object]] = None + self._send_lock: Optional[asyncio.Lock] = None + # Serialisation locks – at most one in-flight RESP / CMD_RESP waiter + self._resp_lock: Optional[asyncio.Lock] = None + self._cmd_resp_lock: Optional[asyncio.Lock] = None + + #: Polling queue for :class:`AsyncResponse` objects received from the + #: server. Available after :meth:`connect` is called. + self.async_response_queue: Optional[asyncio.Queue[AsyncResponse]] = None + + #: Polling queue for :class:`StatusNotification` objects received from + #: the server. Available after :meth:`connect`. + self.status_queue: Optional[asyncio.Queue[StatusNotification]] = None + + # Callback registries – plain dicts (asyncio is single-threaded) + self._status_callbacks: Dict[_SubKey, Optional[AsyncStatusCallback]] = {} + self._async_callbacks: Dict[str, AsyncRespCallback] = {} + + # ------------------------------------------------------------------ + # Connection management + # ------------------------------------------------------------------ + + def _init_async_objects(self) -> None: + """(Re)create asyncio objects bound to the current running loop.""" + self._resp_queue = asyncio.Queue() + self._cmd_resp_queue = asyncio.Queue() + self._send_lock = asyncio.Lock() + self._resp_lock = asyncio.Lock() + self._cmd_resp_lock = asyncio.Lock() + self.async_response_queue = asyncio.Queue() + self.status_queue = asyncio.Queue() + + @property + def connected(self) -> bool: + """``True`` while the writer is open and not being closed.""" + return self._writer is not None and not self._writer.is_closing() + + async def connect(self, host: str, port: int, timeout: float = 5.0) -> None: + """Open a TCP connection and start the background receive task.""" + if self.connected: + raise _RouterConnectionError( + "Already connected; call disconnect() first." + ) + self._init_async_objects() + try: + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=timeout + ) + except asyncio.TimeoutError: + raise _RouterConnectionError( + f"Connection to {host}:{port} timed out after {timeout:.1f}s." + ) from None + except OSError as exc: + raise _RouterConnectionError( + f"Cannot connect to {host}:{port}: {exc}" + ) from exc + self._recv_task = asyncio.ensure_future(self._recv_loop()) + + async def disconnect(self) -> None: + """Close the connection and stop the background receive task. + + Safe to call even if not currently connected. Any coroutines currently + blocked inside :meth:`send_and_wait`, :meth:`post`, or similar methods + will receive a :exc:`ConnectionError` immediately rather than waiting + for their timeout to expire. + """ + # Wake blocked waiters *before* cancelling the recv task. + sentinel = _RouterConnectionError("Disconnected from server.") + if self._resp_queue is not None: + self._resp_queue.put_nowait(sentinel) + if self._cmd_resp_queue is not None: + self._cmd_resp_queue.put_nowait(sentinel) + # Cancel the recv task first; its finally block notifies pending waiters + if self._recv_task is not None and not self._recv_task.done(): + self._recv_task.cancel() + try: + await self._recv_task + except (asyncio.CancelledError, Exception): + pass + self._recv_task = None + + if self._writer is not None: + try: + self._writer.close() + await self._writer.wait_closed() + except OSError: + pass + self._writer = None + self._reader = None + + async def wait_for_server( + self, + host: str, + port: int, + timeout: float = 30.0, + retry_interval: float = 0.5, + ) -> bool: + """Poll until *host*:*port* accepts a connection or *timeout* elapses.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + _, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=1.0 + ) + writer.close() + try: + await writer.wait_closed() + except OSError: + pass + return True + except (OSError, asyncio.TimeoutError): + pass + await asyncio.sleep(retry_interval) + return False + + # ------------------------------------------------------------------ + # Core command methods + # ------------------------------------------------------------------ + + async def send_and_wait( + self, command: str, timeout: float = 5.0 + ) -> CommandResponse: + """Send a **synchronous** command and await the response.""" + wire = encode_packet(command.encode("utf-8"), PacketType.CMD) + assert self._resp_lock is not None + async with self._resp_lock: + await self._send_raw(wire) + return await self._wait_for_resp(timeout) + + async def post(self, command: str, timeout: float = 5.0) -> None: + """Send an **asynchronous** command and await the ``cmd-resp`` handshake.""" + wire = encode_packet(command.encode("utf-8"), PacketType.CMD) + assert self._cmd_resp_lock is not None + async with self._cmd_resp_lock: + await self._send_raw(wire) + await self._wait_for_cmd_resp(timeout) + + async def post_no_reply(self, command: str, timeout: float = 5.0) -> None: + """Send an **async no-reply** command and await the ``cmd-resp`` handshake.""" + wire = encode_packet(command.encode("utf-8"), PacketType.CMD) + assert self._cmd_resp_lock is not None + async with self._cmd_resp_lock: + await self._send_raw(wire) + await self._wait_for_cmd_resp(timeout) + + async def ping(self, timeout: float = 2.0) -> Tuple[bool, float]: + """Send a ``Ping`` command and measure round-trip latency. + + :returns: ``(True, elapsed_seconds)`` on success, ``(False, 0.0)`` on failure. + """ + try: + t0 = time.monotonic() + await self.send_and_wait("Ping", timeout=timeout) + return True, time.monotonic() - t0 + except (_RouterConnectionError, _RouterTimeoutError, ServerError): + return False, 0.0 + + # ------------------------------------------------------------------ + # Router management helpers + # ------------------------------------------------------------------ + + async def list_modules(self, timeout: float = 5.0) -> str: + """Return the server's loaded CSM module list as plain text.""" + return (await self.send_and_wait("List", timeout=timeout)).text + + async def list_api(self, module: str, timeout: float = 5.0) -> str: + """Return the API list for *module* as plain text.""" + return (await self.send_and_wait(f"List API {module}", timeout=timeout)).text + + async def list_states(self, module: str, timeout: float = 5.0) -> str: + """Return the CSM state list for *module* as plain text.""" + return ( + await self.send_and_wait(f"List State {module}", timeout=timeout) + ).text + + async def help(self, module: str, timeout: float = 5.0) -> str: + """Return the help text for *module* as plain text.""" + return (await self.send_and_wait(f"Help {module}", timeout=timeout)).text + + # ------------------------------------------------------------------ + # Status / interrupt subscriptions + # ------------------------------------------------------------------ + + async def subscribe_status( + self, + status_name: str, + module_name: str, + callback: Optional[AsyncStatusCallback] = None, + timeout: float = 5.0, + ) -> None: + """Subscribe to a CSM module's status broadcast. + + Sends ``"@ ->"`` and awaits the + ``cmd-resp`` handshake. Once subscribed, :class:`StatusNotification` + objects will be: + + * delivered to *callback* (if provided – sync or async both accepted), and + * added to :attr:`status_queue`. + """ + # Register the callback before sending to eliminate the race where a + # STATUS packet could arrive before the callback is stored. + self._status_callbacks[(status_name, module_name)] = callback + cmd = f"{status_name}@{module_name} ->" + wire = encode_packet(cmd.encode("utf-8"), PacketType.CMD) + assert self._cmd_resp_lock is not None + try: + async with self._cmd_resp_lock: + await self._send_raw(wire) + await self._wait_for_cmd_resp(timeout) + except Exception: + self._status_callbacks.pop((status_name, module_name), None) + raise + + async def unsubscribe_status( + self, + status_name: str, + module_name: str, + timeout: float = 5.0, + ) -> None: + """Cancel a status subscription.""" + cmd = f"{status_name}@{module_name} ->" + wire = encode_packet(cmd.encode("utf-8"), PacketType.CMD) + assert self._cmd_resp_lock is not None + async with self._cmd_resp_lock: + await self._send_raw(wire) + await self._wait_for_cmd_resp(timeout) + self._status_callbacks.pop((status_name, module_name), None) + + def register_async_callback( + self, + original_command: str, + callback: AsyncRespCallback, + ) -> None: + """Register a callback for ``async-resp`` packets. + + Callbacks may be either a plain callable or an ``async def`` coroutine. + """ + self._async_callbacks[original_command] = callback + + def unregister_async_callback(self, original_command: str) -> None: + """Remove a previously registered async-response callback.""" + self._async_callbacks.pop(original_command, None) + + # ------------------------------------------------------------------ + # Async context-manager support + # ------------------------------------------------------------------ + + async def __aenter__(self) -> AsyncTcpRouterClient: + return self + + async def __aexit__(self, *_args: object) -> None: + await self.disconnect() + + # ------------------------------------------------------------------ + # Internal: send + # ------------------------------------------------------------------ + + async def _send_raw(self, data: bytes) -> None: + if not self.connected: + raise _RouterConnectionError("Not connected.") + assert self._writer is not None + assert self._send_lock is not None + async with self._send_lock: + self._writer.write(data) + await self._writer.drain() + + # ------------------------------------------------------------------ + # Internal: receive loop (background task) + # ------------------------------------------------------------------ + + async def _recv_loop(self) -> None: + """Background task: read frames and dispatch them.""" + assert self._reader is not None + try: + while True: + header = await self._reader.readexactly(HEADER_SIZE) + (data_len,) = struct.unpack("!I", header[:4]) + body = ( + await self._reader.readexactly(data_len) if data_len else b"" + ) + try: + packet = parse_packet(header, body) + except ProtocolError: + continue # skip corrupted frame; keep connection alive + await self._dispatch_packet(packet) + except (asyncio.IncompleteReadError, asyncio.CancelledError, OSError): + pass + finally: + self._notify_disconnect() + + async def _dispatch_packet(self, packet: Packet) -> None: + """Route a received packet to the correct queue and/or callback.""" + assert self._resp_queue is not None + assert self._cmd_resp_queue is not None + assert self.async_response_queue is not None + assert self.status_queue is not None + + ptype = packet.type + + if ptype == PacketType.RESP: + self._resp_queue.put_nowait(packet) + + elif ptype == PacketType.CMD_RESP: + self._cmd_resp_queue.put_nowait(packet) + + elif ptype == PacketType.ASYNC_RESP: + resp = AsyncResponse.from_packet(packet) + self.async_response_queue.put_nowait(resp) + cb = self._async_callbacks.get(resp.original_command) + if cb is not None: + try: + result = cb(resp) # type: ignore[arg-type] + if inspect.isawaitable(result): + await result + except Exception: + pass + + elif ptype in (PacketType.STATUS, PacketType.INTERRUPT): + notif = StatusNotification.from_packet(packet) + self.status_queue.put_nowait(notif) + cb = self._status_callbacks.get( # type: ignore[assignment] + (notif.status_name, notif.module_name) + ) + if cb is not None: + try: + result = cb(notif) # type: ignore[arg-type] + if inspect.isawaitable(result): + await result + except Exception: + pass + + elif ptype == PacketType.ERROR: + err = _parse_server_error(packet) + self._resp_queue.put_nowait(err) + self._cmd_resp_queue.put_nowait(err) + + # PacketType.INFO is silently discarded (welcome / goodbye messages) + + def _notify_disconnect(self) -> None: + """Put sentinels in waiter queues when the connection is lost.""" + if self._resp_queue is None: + return + sentinel = _RouterConnectionError("Connection lost unexpectedly.") + self._resp_queue.put_nowait(sentinel) + self._cmd_resp_queue.put_nowait(sentinel) + + # ------------------------------------------------------------------ + # Internal: synchronised waiters + # ------------------------------------------------------------------ + + async def _wait_for_resp(self, timeout: float) -> CommandResponse: + assert self._resp_queue is not None + try: + item = await asyncio.wait_for(self._resp_queue.get(), timeout=timeout) + except asyncio.TimeoutError: + raise _RouterTimeoutError( + f"No response received within {timeout:.1f}s." + ) from None + if isinstance(item, Exception): + raise item + assert isinstance(item, Packet) + return CommandResponse(raw=item.data) + + async def _wait_for_cmd_resp(self, timeout: float) -> None: + assert self._cmd_resp_queue is not None + try: + item = await asyncio.wait_for( + self._cmd_resp_queue.get(), timeout=timeout + ) + except asyncio.TimeoutError: + raise _RouterTimeoutError( + f"No cmd-resp received within {timeout:.1f}s." + ) from None + if isinstance(item, Exception): + raise item + # CMD_RESP payload is a handshake acknowledgment; discard it diff --git a/SDK/python/tests/__init__.py b/SDK/python/tests/__init__.py new file mode 100644 index 0000000..9cece0a --- /dev/null +++ b/SDK/python/tests/__init__.py @@ -0,0 +1 @@ +# tests package marker diff --git a/SDK/python/tests/conftest.py b/SDK/python/tests/conftest.py new file mode 100644 index 0000000..ae07cc6 --- /dev/null +++ b/SDK/python/tests/conftest.py @@ -0,0 +1,210 @@ +"""Shared pytest fixtures – mock TCP server for integration tests.""" + +from __future__ import annotations + +import queue +import socket +import struct +import threading +from typing import Dict, Optional, Tuple + +import pytest + +from csm_tcp_router_client import HEADER_SIZE, PacketType, encode_packet + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _recv_all(sock: socket.socket, size: int) -> bytes: + """Read exactly *size* bytes from *sock*; returns ``b""`` on EOF.""" + buf = bytearray(size) + view = memoryview(buf) + received = 0 + while received < size: + try: + n = sock.recv_into(view[received:], size - received) + except OSError: + return b"" + if n == 0: + return b"" + received += n + return bytes(buf) + + +# --------------------------------------------------------------------------- +# MockServer +# --------------------------------------------------------------------------- + +class MockServer: + """Minimal TCP server that emulates a CSM-TCP-Router for testing. + + Usage:: + + server = MockServer() + server.start() + # ... connect a TcpRouterClient to server.port ... + server.stop() + + Custom responses can be registered before the client sends commands:: + + server.set_response("My Command", "My Reply") + server.set_error_response("Bad Command", "[Error: 42] bad command") + """ + + def __init__(self) -> None: + self._server_sock: Optional[socket.socket] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self.host: str = "127.0.0.1" + self.port: int = 0 + + #: All raw command strings received from the client, in order. + self.received_commands: queue.Queue[str] = queue.Queue() + + # custom response map: command text -> (PacketType, bytes) + self._responses: Dict[str, Tuple[PacketType, bytes]] = {} + + # Connected client sockets (for push operations like STATUS) + self._client_sockets: list = [] + self._clients_lock = threading.Lock() + + def start(self) -> None: + self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server_sock.bind((self.host, 0)) + self.port = self._server_sock.getsockname()[1] + self._server_sock.listen(5) + self._stop.clear() + self._thread = threading.Thread( + target=self._accept_loop, daemon=True, name="mock-server" + ) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._server_sock: + try: + self._server_sock.close() + except OSError: + pass + if self._thread: + self._thread.join(timeout=2) + + def set_response(self, cmd_text: str, resp_text: str) -> None: + """Register a custom ``RESP`` reply for *cmd_text*.""" + self._responses[cmd_text] = (PacketType.RESP, resp_text.encode("utf-8")) + + def set_error_response(self, cmd_text: str, error_text: str) -> None: + """Register an ``ERROR`` reply for *cmd_text*.""" + self._responses[cmd_text] = (PacketType.ERROR, error_text.encode("utf-8")) + + def push_status(self, payload: str) -> None: + """Push a ``STATUS`` packet to all currently connected clients.""" + wire = encode_packet(payload.encode("utf-8"), PacketType.STATUS) + with self._clients_lock: + for conn in list(self._client_sockets): + try: + conn.sendall(wire) + except OSError: + pass + + def get_received(self, timeout: float = 1.0) -> Optional[str]: + """Return the next received command string, or ``None`` on timeout.""" + try: + return self.received_commands.get(timeout=timeout) + except queue.Empty: + return None + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _accept_loop(self) -> None: + assert self._server_sock is not None + self._server_sock.settimeout(0.5) + while not self._stop.is_set(): + try: + conn, _ = self._server_sock.accept() + except (OSError, socket.timeout): + continue + with self._clients_lock: + self._client_sockets.append(conn) + t = threading.Thread( + target=self._handle_client, args=(conn,), daemon=True + ) + t.start() + + def _handle_client(self, conn: socket.socket) -> None: + # Send welcome info packet on connect + conn.sendall(encode_packet(b"Welcome to mock server", PacketType.INFO)) + conn.settimeout(1.0) + try: + while not self._stop.is_set(): + header = _recv_all(conn, HEADER_SIZE) + if not header: + break + try: + (data_len,) = struct.unpack("!I", header[:4]) + body = _recv_all(conn, data_len) + except (OSError, struct.error): + break + if len(body) != data_len: + break + + type_byte = header[5] # offset 5 == TYPE byte + if type_byte == PacketType.CMD.value: + cmd_text = body.decode("utf-8", errors="replace").strip() + self.received_commands.put(cmd_text) + self._handle_command(conn, cmd_text) + except OSError: + pass + finally: + with self._clients_lock: + try: + self._client_sockets.remove(conn) + except ValueError: + pass + try: + conn.close() + except OSError: + pass + + def _handle_command(self, conn: socket.socket, cmd: str) -> None: + """Respond to a received command.""" + if cmd in self._responses: + ptype, data = self._responses[cmd] + conn.sendall(encode_packet(data, ptype)) + return + + # Built-in defaults + if cmd == "Ping": + conn.sendall(encode_packet(b"Pong", PacketType.RESP)) + elif cmd == "List": + conn.sendall(encode_packet(b"AI\nDIO\nSystem", PacketType.RESP)) + elif cmd.startswith("List API "): + module = cmd[len("List API "):].strip() + payload = f"API: Start -> {module}\nAPI: Stop -> {module}" + conn.sendall(encode_packet(payload.encode(), PacketType.RESP)) + elif cmd.startswith("List State "): + module = cmd[len("List State "):].strip() + payload = f"Idle <- {module}\nRunning <- {module}" + conn.sendall(encode_packet(payload.encode(), PacketType.RESP)) + elif "->" in cmd or "->" in cmd: + conn.sendall(encode_packet(b"", PacketType.CMD_RESP)) + else: + # Generic async handshake for any other command + conn.sendall(encode_packet(b"", PacketType.CMD_RESP)) + + +# --------------------------------------------------------------------------- +# Pytest fixture +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_server(): + """Provide a running :class:`MockServer`; stop it after the test.""" + server = MockServer() + server.start() + yield server + server.stop() diff --git a/SDK/python/tests/test_async_client.py b/SDK/python/tests/test_async_client.py new file mode 100644 index 0000000..07be0c0 --- /dev/null +++ b/SDK/python/tests/test_async_client.py @@ -0,0 +1,454 @@ +"""Tests for AsyncTcpRouterClient – unit tests and integration tests.""" + +from __future__ import annotations + +import asyncio +import time +from typing import List + +import pytest + +from csm_tcp_router_client import ( + AsyncResponse, + AsyncTcpRouterClient, + Packet, + PacketType, + ServerError, + StatusNotification, + _parse_server_error, +) +from csm_tcp_router_client import ConnectionError as RouterConnectionError +from csm_tcp_router_client import TimeoutError as RouterTimeoutError + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_packet(ptype: PacketType, text: str = "") -> Packet: + return Packet(type=ptype, data=text.encode("utf-8")) + + +def _client_with_queues() -> AsyncTcpRouterClient: + """Return a client with asyncio objects pre-initialised (no TCP connection).""" + client = AsyncTcpRouterClient() + client._init_async_objects() + return client + + +# --------------------------------------------------------------------------- +# Unit tests: _parse_server_error (sync – no asyncio needed) +# --------------------------------------------------------------------------- + + +def test_parse_error_plain_text(): + pkt = _make_packet(PacketType.ERROR, "something went wrong") + err = _parse_server_error(pkt) + assert err.message == "something went wrong" + assert err.code == "" + + +def test_parse_error_csm_format(): + pkt = _make_packet(PacketType.ERROR, "[Error: 42] module not found") + err = _parse_server_error(pkt) + assert err.code == "42" + assert err.message == "module not found" + + +def test_parse_error_malformed_bracket(): + pkt = _make_packet(PacketType.ERROR, "[Error: missing close") + err = _parse_server_error(pkt) + assert err.message == "[Error: missing close" + + +# --------------------------------------------------------------------------- +# Unit tests: packet dispatch +# --------------------------------------------------------------------------- + + +async def test_dispatch_resp(): + client = _client_with_queues() + pkt = _make_packet(PacketType.RESP, "ok") + await client._dispatch_packet(pkt) + item = client._resp_queue.get_nowait() + assert isinstance(item, Packet) + assert item.data == b"ok" + + +async def test_dispatch_cmd_resp(): + client = _client_with_queues() + await client._dispatch_packet(_make_packet(PacketType.CMD_RESP)) + assert not client._cmd_resp_queue.empty() + + +async def test_dispatch_error_unblocks_both_queues(): + client = _client_with_queues() + await client._dispatch_packet(_make_packet(PacketType.ERROR, "[Error: 7] bad")) + r = client._resp_queue.get_nowait() + c = client._cmd_resp_queue.get_nowait() + assert isinstance(r, ServerError) and r.code == "7" + assert isinstance(c, ServerError) + + +async def test_dispatch_async_resp_to_queue(): + client = _client_with_queues() + await client._dispatch_packet( + _make_packet(PacketType.ASYNC_RESP, "result <- API: Start -> DIO") + ) + ar = client.async_response_queue.get_nowait() + assert ar.text == "result" + assert ar.original_command == "API: Start -> DIO" + + +async def test_dispatch_async_resp_sync_callback(): + client = _client_with_queues() + received: List[AsyncResponse] = [] + client.register_async_callback("API: Start -> DIO", received.append) + await client._dispatch_packet( + _make_packet(PacketType.ASYNC_RESP, "result <- API: Start -> DIO") + ) + assert len(received) == 1 and received[0].text == "result" + + +async def test_dispatch_async_resp_async_callback(): + client = _client_with_queues() + received: List[AsyncResponse] = [] + + async def async_cb(ar: AsyncResponse) -> None: + received.append(ar) + + client.register_async_callback("cmd", async_cb) + await client._dispatch_packet(_make_packet(PacketType.ASYNC_RESP, "val <- cmd")) + assert len(received) == 1 + + +async def test_dispatch_status_to_queue(): + client = _client_with_queues() + await client._dispatch_packet(_make_packet(PacketType.STATUS, "Status >> 42 <- AI")) + notif = client.status_queue.get_nowait() + assert notif.status_name == "Status" + assert notif.data == "42" + assert notif.module_name == "AI" + + +async def test_dispatch_status_sync_callback(): + client = _client_with_queues() + received: List[StatusNotification] = [] + client._status_callbacks[("Status", "AI")] = received.append + await client._dispatch_packet(_make_packet(PacketType.STATUS, "Status >> v1 <- AI")) + assert len(received) == 1 and received[0].data == "v1" + + +async def test_dispatch_status_async_callback(): + client = _client_with_queues() + received: List[StatusNotification] = [] + + async def async_cb(notif: StatusNotification) -> None: + received.append(notif) + + client._status_callbacks[("Temp", "Sensor")] = async_cb + await client._dispatch_packet(_make_packet(PacketType.STATUS, "Temp >> 25.5 <- Sensor")) + assert len(received) == 1 and received[0].data == "25.5" + + +async def test_dispatch_interrupt_to_queue(): + client = _client_with_queues() + await client._dispatch_packet( + _make_packet(PacketType.INTERRUPT, "Stop >> 1 <- AI") + ) + notif = client.status_queue.get_nowait() + assert notif.packet_type == PacketType.INTERRUPT + + +async def test_dispatch_info_silently_discarded(): + client = _client_with_queues() + await client._dispatch_packet(_make_packet(PacketType.INFO, "Welcome")) + assert client._resp_queue.empty() + assert client._cmd_resp_queue.empty() + + +async def test_notify_disconnect_puts_sentinels(): + client = _client_with_queues() + client._notify_disconnect() + r = client._resp_queue.get_nowait() + c = client._cmd_resp_queue.get_nowait() + assert isinstance(r, RouterConnectionError) + assert isinstance(c, RouterConnectionError) + + +# --------------------------------------------------------------------------- +# Unit tests: callback management +# --------------------------------------------------------------------------- + + +def test_unregister_async_callback_noop_if_missing(): + client = AsyncTcpRouterClient() + client.unregister_async_callback("nonexistent") # must not raise + + +# --------------------------------------------------------------------------- +# Unit tests: timeout waiters +# --------------------------------------------------------------------------- + + +async def test_wait_for_resp_timeout(): + client = _client_with_queues() + with pytest.raises(RouterTimeoutError, match=r"0\.1s"): + await client._wait_for_resp(timeout=0.1) + + +async def test_wait_for_cmd_resp_timeout(): + client = _client_with_queues() + with pytest.raises(RouterTimeoutError, match=r"0\.1s"): + await client._wait_for_cmd_resp(timeout=0.1) + + +async def test_wait_for_resp_raises_server_error(): + client = _client_with_queues() + client._resp_queue.put_nowait(ServerError("boom", "5")) + with pytest.raises(ServerError, match="boom"): + await client._wait_for_resp(timeout=1.0) + + +async def test_wait_for_resp_raises_connection_error(): + client = _client_with_queues() + client._resp_queue.put_nowait(RouterConnectionError("lost")) + with pytest.raises(RouterConnectionError): + await client._wait_for_resp(timeout=1.0) + + +# --------------------------------------------------------------------------- +# Integration tests (real TCP via MockServer fixture) +# --------------------------------------------------------------------------- + + +class TestConnection: + async def test_connect_and_disconnect(self, mock_server): + client = AsyncTcpRouterClient() + await client.connect(mock_server.host, mock_server.port) + assert client.connected + await client.disconnect() + assert not client.connected + + async def test_async_context_manager(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + assert client.connected + assert not client.connected + + async def test_connect_already_connected_raises(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + with pytest.raises(RouterConnectionError, match="Already connected"): + await client.connect(mock_server.host, mock_server.port) + + async def test_connect_bad_port_raises(self): + client = AsyncTcpRouterClient() + with pytest.raises(RouterConnectionError): + await client.connect("127.0.0.1", 1, timeout=0.5) + + async def test_wait_for_server_success(self, mock_server): + client = AsyncTcpRouterClient() + ok = await client.wait_for_server( + mock_server.host, mock_server.port, timeout=5.0, retry_interval=0.1 + ) + assert ok is True + + async def test_wait_for_server_timeout(self): + client = AsyncTcpRouterClient() + ok = await client.wait_for_server("127.0.0.1", 1, timeout=0.3, retry_interval=0.1) + assert ok is False + + +class TestPing: + async def test_ping_returns_true_and_elapsed(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + ok, elapsed = await client.ping(timeout=2.0) + assert ok is True + assert elapsed > 0 + + +class TestSendAndWait: + async def test_list_modules(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + text = await client.list_modules(timeout=2.0) + assert "AI" in text and "DIO" in text + + async def test_list_api(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + text = await client.list_api("DAQmx", timeout=2.0) + assert "DAQmx" in text + + async def test_list_states(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + text = await client.list_states("AI", timeout=2.0) + assert "AI" in text + + async def test_custom_command(self, mock_server): + mock_server.set_response("My Cmd", "My Reply") + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + resp = await client.send_and_wait("My Cmd", timeout=2.0) + assert resp.text == "My Reply" + assert resp.raw == b"My Reply" + + async def test_server_error_raises(self, mock_server): + mock_server.set_error_response("Bad Cmd", "[Error: 9] nope") + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + with pytest.raises(ServerError) as exc_info: + await client.send_and_wait("Bad Cmd", timeout=2.0) + assert exc_info.value.code == "9" + assert exc_info.value.message == "nope" + + async def test_timeout_when_server_sends_only_cmd_resp(self, mock_server): + """send_and_wait should time out when server sends CMD_RESP instead of RESP.""" + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + with pytest.raises(RouterTimeoutError): + # Default mock sends CMD_RESP for unknown commands, never RESP + await client.send_and_wait("Unknown Async", timeout=0.3) + + async def test_concurrent_commands(self, mock_server): + """Two sequential send_and_wait calls on the same client both succeed.""" + mock_server.set_response("Cmd1", "R1") + mock_server.set_response("Cmd2", "R2") + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + r1 = await client.send_and_wait("Cmd1", timeout=2.0) + r2 = await client.send_and_wait("Cmd2", timeout=2.0) + assert r1.text == "R1" + assert r2.text == "R2" + + +class TestPost: + async def test_post_command(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.post("API: Start -> DIO", timeout=2.0) + + async def test_post_no_reply(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.post_no_reply("API: Reset ->| DIO", timeout=2.0) + + async def test_post_error_raises(self, mock_server): + mock_server.set_error_response("API: Start -> DIO", "module missing") + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + with pytest.raises(ServerError): + await client.post("API: Start -> DIO", timeout=2.0) + + +class TestSubscriptions: + async def test_subscribe_receives_via_queue(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.subscribe_status("Status", "AI", timeout=2.0) + mock_server.push_status("Status >> 99 <- AI") + assert client.status_queue is not None + notif = await asyncio.wait_for(client.status_queue.get(), timeout=2.0) + assert notif.status_name == "Status" + assert notif.data == "99" + assert notif.module_name == "AI" + + async def test_subscribe_sync_callback(self, mock_server): + received: List[StatusNotification] = [] + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.subscribe_status( + "Status", "AI", callback=received.append, timeout=2.0 + ) + mock_server.push_status("Status >> hello <- AI") + await asyncio.sleep(0.3) + assert len(received) == 1 and received[0].data == "hello" + + async def test_subscribe_async_callback(self, mock_server): + received: List[StatusNotification] = [] + + async def async_cb(notif: StatusNotification) -> None: + received.append(notif) + + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.subscribe_status("Status", "AI", callback=async_cb, timeout=2.0) + mock_server.push_status("Status >> world <- AI") + await asyncio.sleep(0.3) + assert len(received) == 1 and received[0].data == "world" + + async def test_unsubscribe_stops_callback(self, mock_server): + received: List[StatusNotification] = [] + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.subscribe_status( + "Status", "AI", callback=received.append, timeout=2.0 + ) + await client.unsubscribe_status("Status", "AI", timeout=2.0) + mock_server.push_status("Status >> ignored <- AI") + await asyncio.sleep(0.2) + assert len(received) == 0 + + async def test_multiple_notifications_in_order(self, mock_server): + received: List[StatusNotification] = [] + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.subscribe_status( + "Temp", "Sensor", callback=received.append, timeout=2.0 + ) + for i in range(5): + mock_server.push_status(f"Temp >> {i} <- Sensor") + await asyncio.sleep(0.5) + assert len(received) == 5 + assert [n.data for n in received] == ["0", "1", "2", "3", "4"] + + async def test_subscribe_error_rolls_back_callback(self, mock_server): + mock_server.set_error_response( + "Status@AI ->", "[Error: 1] denied" + ) + received: List[StatusNotification] = [] + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + with pytest.raises(ServerError): + await client.subscribe_status( + "Status", "AI", callback=received.append, timeout=2.0 + ) + # Callback must be removed on failure + assert ("Status", "AI") not in client._status_callbacks + + +class TestConnectedProperty: + async def test_not_connected_before_connect(self): + client = AsyncTcpRouterClient() + assert not client.connected + + async def test_connected_after_connect(self, mock_server): + client = AsyncTcpRouterClient() + await client.connect(mock_server.host, mock_server.port) + assert client.connected + await client.disconnect() + + async def test_not_connected_after_disconnect(self, mock_server): + client = AsyncTcpRouterClient() + await client.connect(mock_server.host, mock_server.port) + await client.disconnect() + assert not client.connected + + async def test_send_when_not_connected_raises(self): + client = AsyncTcpRouterClient() + client._init_async_objects() + with pytest.raises(RouterConnectionError, match="Not connected"): + await client.send_and_wait("Ping", timeout=0.1) + + +class TestTimingAndPerformance: + async def test_elapsed_time_is_positive(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + t0 = time.monotonic() + await client.send_and_wait("Ping", timeout=2.0) + elapsed = time.monotonic() - t0 + assert elapsed >= 0 diff --git a/SDK/python/tests/test_client.py b/SDK/python/tests/test_client.py new file mode 100644 index 0000000..14cb2b9 --- /dev/null +++ b/SDK/python/tests/test_client.py @@ -0,0 +1,302 @@ +"""Unit tests for TcpRouterClient using a mock transport.""" + +from __future__ import annotations + +import threading +import time +from typing import List +from unittest.mock import MagicMock, patch + +import pytest + +from csm_tcp_router_client import ( + AsyncResponse, + Packet, + PacketType, + ServerError, + StatusNotification, + TcpRouterClient, + _parse_server_error, +) +from csm_tcp_router_client import ConnectionError as RouterConnectionError +from csm_tcp_router_client import TimeoutError as RouterTimeoutError + +# --------------------------------------------------------------------------- +# Helpers: inject packets directly into the client's dispatch method +# --------------------------------------------------------------------------- + +def make_packet(ptype: PacketType, text: str = "") -> Packet: + return Packet(type=ptype, data=text.encode("utf-8")) + + +def inject(client: TcpRouterClient, packet: Packet) -> None: + """Simulate the receive thread delivering a packet.""" + client._on_packet(packet) + + +# --------------------------------------------------------------------------- +# _parse_server_error +# --------------------------------------------------------------------------- + +class TestParseServerError: + def test_plain_message(self): + pkt = make_packet(PacketType.ERROR, "something went wrong") + err = _parse_server_error(pkt) + assert err.message == "something went wrong" + assert err.code == "" + + def test_csm_format(self): + pkt = make_packet(PacketType.ERROR, "[Error: 42] module not found") + err = _parse_server_error(pkt) + assert err.code == "42" + assert err.message == "module not found" + assert str(err) == "[Error: 42] module not found" + + def test_csm_format_no_message(self): + pkt = make_packet(PacketType.ERROR, "[Error: 0]") + err = _parse_server_error(pkt) + assert err.code == "0" + assert err.message == "" + + def test_malformed_bracket_no_crash(self): + pkt = make_packet(PacketType.ERROR, "[Error: no closing bracket") + err = _parse_server_error(pkt) + assert err.code == "" + assert "no closing bracket" in err.message + + +# --------------------------------------------------------------------------- +# TcpRouterClient internal dispatch +# --------------------------------------------------------------------------- + +class TestPacketDispatch: + def _client_no_transport(self) -> TcpRouterClient: + """Return a client with a mocked (never-connecting) transport.""" + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = True + client._transport.send_raw = MagicMock() + return client + + def test_resp_unblocks_wait_for_resp(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.RESP, "ok") + threading.Timer(0.05, inject, args=(client, pkt)).start() + resp = client._wait_for_resp(timeout=1.0) + assert resp.text == "ok" + + def test_cmd_resp_unblocks_wait_for_cmd_resp(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.CMD_RESP) + threading.Timer(0.05, inject, args=(client, pkt)).start() + client._wait_for_cmd_resp(timeout=1.0) # should not raise + + def test_error_unblocks_resp_waiter_with_exception(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.ERROR, "[Error: 1] bad") + threading.Timer(0.05, inject, args=(client, pkt)).start() + with pytest.raises(ServerError): + client._wait_for_resp(timeout=1.0) + + def test_error_unblocks_cmd_resp_waiter_with_exception(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.ERROR, "no module") + threading.Timer(0.05, inject, args=(client, pkt)).start() + with pytest.raises(ServerError): + client._wait_for_cmd_resp(timeout=1.0) + + def test_async_resp_added_to_queue(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.ASYNC_RESP, "result <- API: Start -> DIO") + inject(client, pkt) + ar = client.async_response_queue.get(timeout=0.5) + assert ar.text == "result" + assert ar.original_command == "API: Start -> DIO" + + def test_async_resp_calls_callback(self): + client = self._client_no_transport() + received: List[AsyncResponse] = [] + client.register_async_callback("API: Start -> DIO", received.append) + pkt = make_packet(PacketType.ASYNC_RESP, "result <- API: Start -> DIO") + inject(client, pkt) + time.sleep(0.05) + assert len(received) == 1 + assert received[0].text == "result" + + def test_status_added_to_queue(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.STATUS, "Status >> value42 <- AI") + inject(client, pkt) + notif = client.status_queue.get(timeout=0.5) + assert notif.status_name == "Status" + assert notif.data == "value42" + assert notif.module_name == "AI" + + def test_status_calls_registered_callback(self): + client = self._client_no_transport() + received: List[StatusNotification] = [] + + with patch.object(client._transport, "send_raw"), patch.object(client, "_wait_for_cmd_resp"): + client._status_callbacks[("Status", "AI")] = received.append + + pkt = make_packet(PacketType.STATUS, "Status >> v1 <- AI") + inject(client, pkt) + time.sleep(0.05) + assert len(received) == 1 + assert received[0].data == "v1" + + def test_interrupt_added_to_status_queue(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.INTERRUPT, "Alarm >> fire <- Safety") + inject(client, pkt) + notif = client.status_queue.get(timeout=0.5) + assert notif.packet_type == PacketType.INTERRUPT + assert notif.status_name == "Alarm" + + def test_info_packet_silently_discarded(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.INFO, "Welcome to the server") + inject(client, pkt) + assert client._resp_queue.empty() + assert client._cmd_resp_queue.empty() + + def test_on_disconnect_unblocks_waiters(self): + client = self._client_no_transport() + threading.Timer(0.05, client._on_disconnect).start() + with pytest.raises(RouterConnectionError): + client._wait_for_resp(timeout=1.0) + + +# --------------------------------------------------------------------------- +# Timeout behaviour +# --------------------------------------------------------------------------- + +class TestTimeouts: + def _client(self) -> TcpRouterClient: + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = True + client._transport.send_raw = MagicMock() + return client + + def test_wait_for_resp_timeout(self): + client = self._client() + with pytest.raises(RouterTimeoutError, match=r"0\.1s"): + client._wait_for_resp(timeout=0.1) + + def test_wait_for_cmd_resp_timeout(self): + client = self._client() + with pytest.raises(RouterTimeoutError, match=r"0\.1s"): + client._wait_for_cmd_resp(timeout=0.1) + + +# --------------------------------------------------------------------------- +# ping convenience method +# --------------------------------------------------------------------------- + +class TestPing: + def test_ping_success_returns_true_and_elapsed(self): + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = True + client._transport.send_raw = MagicMock() + + threading.Timer( + 0.02, + inject, + args=(client, make_packet(PacketType.RESP, "Pong")), + ).start() + ok, elapsed = client.ping(timeout=1.0) + assert ok is True + assert elapsed > 0 + + def test_ping_failure_returns_false(self): + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = True + client._transport.send_raw = MagicMock() + # No packet injected → times out + ok, elapsed = client.ping(timeout=0.05) + assert ok is False + assert elapsed == 0.0 + + +# --------------------------------------------------------------------------- +# Context manager +# --------------------------------------------------------------------------- + +def test_context_manager_calls_disconnect(): + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = False + + with client: + pass + + client._transport.disconnect.assert_called_once() + + +# --------------------------------------------------------------------------- +# subscribe_status / unsubscribe_status +# --------------------------------------------------------------------------- + +class TestSubscriptions: + def _client_with_mock_handshake(self) -> TcpRouterClient: + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = True + client._transport.send_raw = MagicMock() + # Patch _wait_for_cmd_resp to succeed immediately + client._wait_for_cmd_resp = MagicMock() + return client + + def test_subscribe_stores_callback(self): + client = self._client_with_mock_handshake() + cb = MagicMock() + client.subscribe_status("Status", "AI", callback=cb) + assert client._status_callbacks[("Status", "AI")] is cb + + def test_subscribe_sends_register_command(self): + client = self._client_with_mock_handshake() + client.subscribe_status("Status", "AI") + wire = client._transport.send_raw.call_args[0][0] + assert b"Status@AI ->" in wire + + def test_unsubscribe_removes_callback(self): + client = self._client_with_mock_handshake() + client._status_callbacks[("Status", "AI")] = MagicMock() + client.unsubscribe_status("Status", "AI") + assert ("Status", "AI") not in client._status_callbacks + + def test_unsubscribe_sends_unregister_command(self): + client = self._client_with_mock_handshake() + client.unsubscribe_status("Status", "AI") + wire = client._transport.send_raw.call_args[0][0] + assert b"Status@AI ->" in wire + + def test_subscribe_cleans_up_callback_on_error(self): + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = True + client._transport.send_raw = MagicMock() + # Make _wait_for_cmd_resp raise + client._wait_for_cmd_resp = MagicMock(side_effect=RouterTimeoutError("t/o")) + + cb = MagicMock() + with pytest.raises(RouterTimeoutError): + client.subscribe_status("Status", "AI", callback=cb) + assert ("Status", "AI") not in client._status_callbacks + + +# --------------------------------------------------------------------------- +# register_async_callback / unregister_async_callback +# --------------------------------------------------------------------------- + +class TestAsyncCallbacks: + def test_register_and_unregister(self): + client = TcpRouterClient() + cb = MagicMock() + client.register_async_callback("cmd", cb) + assert client._async_callbacks["cmd"] is cb + client.unregister_async_callback("cmd") + assert "cmd" not in client._async_callbacks diff --git a/SDK/python/tests/test_integration.py b/SDK/python/tests/test_integration.py new file mode 100644 index 0000000..86cf964 --- /dev/null +++ b/SDK/python/tests/test_integration.py @@ -0,0 +1,218 @@ +"""Integration tests: real TcpRouterClient talking to MockServer over localhost TCP.""" + +from __future__ import annotations + +import time +from typing import List + +import pytest + +from csm_tcp_router_client import ServerError, StatusNotification, TcpRouterClient +from csm_tcp_router_client import TimeoutError as RouterTimeoutError + +# All tests in this module use the `mock_server` fixture from conftest.py. + + +# --------------------------------------------------------------------------- +# Connection lifecycle +# --------------------------------------------------------------------------- + +class TestConnection: + def test_connect_and_disconnect(self, mock_server): + client = TcpRouterClient() + client.connect(mock_server.host, mock_server.port) + assert client.connected + client.disconnect() + assert not client.connected + + def test_context_manager(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + assert client.connected + assert not client.connected + + def test_connect_bad_port_raises(self): + client = TcpRouterClient() + from csm_tcp_router_client import ConnectionError as RouterConnectionError + with pytest.raises(RouterConnectionError): + client.connect("127.0.0.1", 1, timeout=0.5) + + def test_wait_for_server_success(self, mock_server): + client = TcpRouterClient() + ok = client.wait_for_server( + mock_server.host, mock_server.port, timeout=5.0, retry_interval=0.1 + ) + assert ok is True + + def test_wait_for_server_timeout(self): + client = TcpRouterClient() + ok = client.wait_for_server("127.0.0.1", 1, timeout=0.3, retry_interval=0.1) + assert ok is False + + +# --------------------------------------------------------------------------- +# Ping +# --------------------------------------------------------------------------- + +class TestPing: + def test_ping_returns_true_and_elapsed(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + ok, elapsed = client.ping(timeout=2.0) + assert ok is True + assert elapsed > 0 + + +# --------------------------------------------------------------------------- +# Synchronous command (send_and_wait) +# --------------------------------------------------------------------------- + +class TestSendAndWait: + def test_list_modules(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + resp = client.send_and_wait("List", timeout=2.0) + assert "AI" in resp.text + assert "DIO" in resp.text + + def test_list_api(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + text = client.list_api("DAQmx", timeout=2.0) + assert "DAQmx" in text + + def test_list_states(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + text = client.list_states("DAQmx", timeout=2.0) + assert "Idle" in text or "Running" in text + + def test_custom_command_response(self, mock_server): + mock_server.set_response("My Command", "My Reply") + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + resp = client.send_and_wait("My Command", timeout=2.0) + assert resp.text == "My Reply" + + def test_server_error_raises(self, mock_server): + mock_server.set_error_response("Bad Command", "[Error: 7] not found") + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + with pytest.raises(ServerError) as exc_info: + client.send_and_wait("Bad Command", timeout=2.0) + assert exc_info.value.code == "7" + + def test_list_modules_helper(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + text = client.list_modules(timeout=2.0) + assert "AI" in text + + def test_command_received_by_server(self, mock_server): + mock_server.set_response("API: Probe -@ Sensor", "ok") + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.send_and_wait("API: Probe -@ Sensor", timeout=2.0) + received = mock_server.get_received(timeout=0.5) + assert received == "API: Probe -@ Sensor" + + +# --------------------------------------------------------------------------- +# Async command (post) +# --------------------------------------------------------------------------- + +class TestPost: + def test_post_command_sends_and_receives_handshake(self, mock_server): + # MockServer sends CMD_RESP for unknown commands by default + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.post("API: Start -> DIO", timeout=2.0) # should not raise + + def test_post_no_reply_sends_and_receives_handshake(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.post_no_reply("API: Reset ->| DIO", timeout=2.0) + + def test_post_error_raises(self, mock_server): + mock_server.set_error_response("API: Start -> DIO", "module missing") + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + with pytest.raises(ServerError): + client.post("API: Start -> DIO", timeout=2.0) + + +# --------------------------------------------------------------------------- +# Status subscriptions +# --------------------------------------------------------------------------- + +class TestStatusSubscriptions: + def test_subscribe_receives_status_via_queue(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.subscribe_status("Status", "AI", timeout=2.0) + + # Server pushes a STATUS packet + mock_server.push_status("Status >> 42.5 <- AI") + + notif = client.status_queue.get(timeout=2.0) + assert notif.status_name == "Status" + assert notif.data == "42.5" + assert notif.module_name == "AI" + + def test_subscribe_callback_is_invoked(self, mock_server): + received: List[StatusNotification] = [] + + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.subscribe_status("Status", "AI", callback=received.append, timeout=2.0) + mock_server.push_status("Status >> hello <- AI") + time.sleep(0.3) + + assert len(received) == 1 + assert received[0].data == "hello" + + def test_unsubscribe_removes_callback(self, mock_server): + received: List[StatusNotification] = [] + + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.subscribe_status("Status", "AI", callback=received.append, timeout=2.0) + client.unsubscribe_status("Status", "AI", timeout=2.0) + mock_server.push_status("Status >> ignored <- AI") + time.sleep(0.3) + + # Callback was removed so it should not have been called + assert len(received) == 0 + + def test_multiple_status_notifications(self, mock_server): + received: List[StatusNotification] = [] + + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.subscribe_status("Temp", "Sensor", callback=received.append, timeout=2.0) + for i in range(5): + mock_server.push_status(f"Temp >> {i} <- Sensor") + time.sleep(0.5) + + assert len(received) == 5 + values = [n.data for n in received] + assert values == ["0", "1", "2", "3", "4"] + + +# --------------------------------------------------------------------------- +# Timeout on disconnect +# --------------------------------------------------------------------------- + +class TestDisconnectBehaviour: + def test_wait_raises_timeout_when_no_resp(self, mock_server): + """send_and_wait raises TimeoutError when the server sends CMD_RESP instead of RESP. + + The mock server's default handler for unknown commands sends a CMD_RESP + handshake, which goes to the cmd_resp queue. send_and_wait waits on + the resp queue, so it must time out. + """ + # "Unknown Sync Command" has no registered response → server sends CMD_RESP + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + with pytest.raises(RouterTimeoutError): + client.send_and_wait("Unknown Sync Command", timeout=0.3) diff --git a/SDK/python/tests/test_protocol.py b/SDK/python/tests/test_protocol.py new file mode 100644 index 0000000..41d5223 --- /dev/null +++ b/SDK/python/tests/test_protocol.py @@ -0,0 +1,154 @@ +"""Unit tests for the protocol v0 codec (_protocol.py).""" + +from __future__ import annotations + +import struct + +import pytest + +from csm_tcp_router_client import ( + HEADER_SIZE, + PROTOCOL_VERSION, + PacketType, + ProtocolError, + decode_header, + encode_packet, + parse_packet, +) + +# --------------------------------------------------------------------------- +# encode_packet +# --------------------------------------------------------------------------- + +class TestEncodePacket: + def test_returns_header_plus_body(self): + data = b"hello" + wire = encode_packet(data, PacketType.CMD) + assert len(wire) == HEADER_SIZE + len(data) + + def test_header_format(self): + data = b"hello" + wire = encode_packet(data, PacketType.CMD) + data_len, version, type_byte, flag1, flag2 = struct.unpack( + "!IBBBB", wire[:HEADER_SIZE] + ) + assert data_len == len(data) + assert version == PROTOCOL_VERSION + assert type_byte == PacketType.CMD.value + assert flag1 == 0 + assert flag2 == 0 + + def test_body_appended_verbatim(self): + data = b"test payload" + wire = encode_packet(data, PacketType.RESP) + assert wire[HEADER_SIZE:] == data + + def test_empty_body(self): + wire = encode_packet(b"", PacketType.CMD_RESP) + assert len(wire) == HEADER_SIZE + (data_len,) = struct.unpack("!I", wire[:4]) + assert data_len == 0 + + def test_custom_flags(self): + wire = encode_packet(b"x", PacketType.INFO, flag1=0xAB, flag2=0xCD) + _, _, _, flag1, flag2 = struct.unpack("!IBBBB", wire[:HEADER_SIZE]) + assert flag1 == 0xAB + assert flag2 == 0xCD + + def test_all_packet_types_encode(self): + for ptype in PacketType: + wire = encode_packet(b"data", ptype) + assert wire[5] == ptype.value # TYPE byte at offset 5 + + def test_utf8_command_string(self): + cmd = "API: Start Sampling -@ DAQmx" + wire = encode_packet(cmd.encode("utf-8"), PacketType.CMD) + assert wire[HEADER_SIZE:] == cmd.encode("utf-8") + + def test_large_payload_length_field(self): + data = b"x" * 1024 + wire = encode_packet(data, PacketType.RESP) + (data_len,) = struct.unpack("!I", wire[:4]) + assert data_len == 1024 + + +# --------------------------------------------------------------------------- +# decode_header +# --------------------------------------------------------------------------- + +class TestDecodeHeader: + def test_round_trip(self): + wire = encode_packet(b"body", PacketType.ASYNC_RESP, flag1=1, flag2=2) + data_len, version, type_byte, flag1, flag2 = decode_header( + wire[:HEADER_SIZE] + ) + assert data_len == 4 + assert version == PROTOCOL_VERSION + assert type_byte == PacketType.ASYNC_RESP.value + assert flag1 == 1 + assert flag2 == 2 + + def test_wrong_length_raises(self): + with pytest.raises(ProtocolError, match="header"): + decode_header(b"\x00" * 7) + + def test_zero_length_raises(self): + with pytest.raises(ProtocolError): + decode_header(b"") + + +# --------------------------------------------------------------------------- +# parse_packet +# --------------------------------------------------------------------------- + +class TestParsePacket: + def _make_wire(self, data: bytes, ptype: PacketType) -> tuple: + wire = encode_packet(data, ptype) + return wire[:HEADER_SIZE], wire[HEADER_SIZE:] + + def test_basic_round_trip(self): + header, body = self._make_wire(b"hello", PacketType.RESP) + pkt = parse_packet(header, body) + assert pkt.type == PacketType.RESP + assert pkt.data == b"hello" + assert pkt.version == PROTOCOL_VERSION + + def test_all_known_types(self): + for ptype in PacketType: + header, body = self._make_wire(b"data", ptype) + pkt = parse_packet(header, body) + assert pkt.type == ptype + + def test_unknown_type_mapped_to_info(self): + # Manually craft a packet with an unknown type byte (0xFF) + raw_header = struct.pack("!IBBBB", 4, PROTOCOL_VERSION, 0xFF, 0, 0) + pkt = parse_packet(raw_header, b"data") + assert pkt.type == PacketType.INFO + + def test_body_length_mismatch_raises(self): + header, _ = self._make_wire(b"hello", PacketType.CMD) + with pytest.raises(ProtocolError, match="mismatch"): + parse_packet(header, b"hi") # shorter body + + def test_empty_body(self): + header, body = self._make_wire(b"", PacketType.CMD_RESP) + pkt = parse_packet(header, body) + assert pkt.data == b"" + + def test_flags_preserved(self): + wire = encode_packet(b"x", PacketType.STATUS, flag1=3, flag2=7) + pkt = parse_packet(wire[:HEADER_SIZE], wire[HEADER_SIZE:]) + assert pkt.flag1 == 3 + assert pkt.flag2 == 7 + + def test_header_too_short_raises(self): + with pytest.raises(ProtocolError): + parse_packet(b"\x00" * 4, b"") + + +# --------------------------------------------------------------------------- +# HEADER_SIZE constant +# --------------------------------------------------------------------------- + +def test_header_size_is_eight(): + assert HEADER_SIZE == 8 diff --git a/src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(en-us)/VI Description(en-us) - CSM-TCP-Router.md b/src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(en-us)/VI Description(en-us) - CSM-TCP-Router.md new file mode 100644 index 0000000..8f7ae0a --- /dev/null +++ b/src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(en-us)/VI Description(en-us) - CSM-TCP-Router.md @@ -0,0 +1,95 @@ +# CSM-TCP-Router + +## API + +> [!NOTE] +> CSM-TCP-Router API Scope +> +> CSM-TCP-Router APIs are split into two parts: +> - Server VIs: Start and host the TCP router service for CSM modules. +> - Client VIs: Connect to the router and send/receive commands. +> +> The API list below is inferred from current project structure, VI naming, and shipped examples. + +## Server VIs + +### CSM-TCP-Router.vi +Core router VI in `src/_addons/TCP-Router/`. + +Starts the CSM TCP router communication layer, handles packet routing, and serves Router built-in management commands. + +### CSM-TCP-Router(Server).vi +Server startup VI in `src/Server/`. + +Standard runnable entry VI used by the example project to host CSM modules through CSM-TCP-Router. + +### Router Built-in Commands +Commands provided by the router service side: + +- `List`: List all available CSM modules. +- `List API`: List exposed APIs of a specified module. +- `List State`: List CSM states of a specified module. +- `Help`: Return module help text from VI Description. +- `Refresh lvcsm`: Refresh cached lvcsm data. + +## Client VIs + +Client API VIs are under `src/_addons/TCP-Router/ClientAPI/`. + +### Obtain.vi +Create and connect a client session to the TCP router server. + +### Release.vi +Release a client session and related resources. + +### Send Message and Wait for Reply.vi +Send a synchronous command and wait for the final response. + +### Post Message.vi +Post an asynchronous command (non-blocking for final response). + +### Post No-Rep Message.vi +Post an asynchronous command that does not require final response. + +### Ping.vi +Check server reachability and return communication elapsed time. + +### Wait for Server.vi +Wait until the server is reachable or timeout is reached. + +### Register Status Change.vi +Register a status-change subscription callback. + +### Unregister Status Change.vi +Unregister a status-change subscription callback. + +> [!NOTE] +> `Register Status Change.vi` / `Unregister Status Change.vi` are kept for compatibility. +> For new integrations, prefer the `... for Client.vi` variants. + +### Register Status for Client.vi +Register status subscription for a specified client context. + +### Unregister Status for Client.vi +Unregister status subscription for a specified client context. + +### Status Queue.vi +Receive status updates through queue-based API. + +### ASync-Response Queue.vi +Receive asynchronous command responses through queue-based API. + +### ASync-Response User Event.vi +Receive asynchronous command responses through User Event API. + +### Register Broadcast.vi +Register broadcast-message subscription. + +### Unregister Broadcast.vi +Unregister broadcast-message subscription. + +### Register Broadcast for Client.vi +Register broadcast-message subscription for a specified client context. + +### Unregister Broadcast for Client.vi +Unregister broadcast-message subscription for a specified client context. diff --git a/src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(zh-cn)/VI Description(zh-cn) - CSM-TCP-Router.md b/src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(zh-cn)/VI Description(zh-cn) - CSM-TCP-Router.md new file mode 100644 index 0000000..c3f13f1 --- /dev/null +++ b/src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(zh-cn)/VI Description(zh-cn) - CSM-TCP-Router.md @@ -0,0 +1,95 @@ +# CSM-TCP-Router + +## API + +> [!NOTE] +> CSM-TCP-Router API 范围 +> +> CSM-TCP-Router 的 API 分为两部分: +> - Server 侧 VI:启动并承载 CSM 模块的 TCP Router 服务。 +> - Client 侧 VI:连接 Router 并发送/接收指令。 +> +> 下述 API 清单依据当前项目结构、VI 命名和示例工程推断整理。 + +## Server 侧 VI + +### CSM-TCP-Router.vi +位于 `src/_addons/TCP-Router/` 的核心 Router VI。 + +用于启动 CSM TCP Router 通讯层,处理数据包路由,并提供 Router 内建管理指令。 + +### CSM-TCP-Router(Server).vi +位于 `src/Server/` 的服务端启动 VI。 + +作为示例工程的标准入口 VI,用于通过 CSM-TCP-Router 对外承载 CSM 模块。 + +### Router 内建指令 +由 Router 服务端提供的内建指令: + +- `List`:列出所有可用 CSM 模块。 +- `List API`:列出指定模块暴露的 API。 +- `List State`:列出指定模块可用的 CSM 状态。 +- `Help`:返回模块 VI Description 中的帮助文本。 +- `Refresh lvcsm`:刷新 lvcsm 缓存数据。 + +## Client 侧 VI + +Client API 位于 `src/_addons/TCP-Router/ClientAPI/`。 + +### Obtain.vi +创建并连接到 TCP Router 服务端的客户端会话。 + +### Release.vi +释放客户端会话及相关资源。 + +### Send Message and Wait for Reply.vi +发送同步指令并等待最终响应。 + +### Post Message.vi +发送异步指令(调用不阻塞等待最终响应)。 + +### Post No-Rep Message.vi +发送无需最终响应的异步指令。 + +### Ping.vi +检查服务端可达性并返回通讯耗时。 + +### Wait for Server.vi +等待服务端可连接,直到成功或超时。 + +### Register Status Change.vi +注册状态变更订阅回调。 + +### Unregister Status Change.vi +取消状态变更订阅回调。 + +> [!NOTE] +> `Register Status Change.vi` / `Unregister Status Change.vi` 主要用于兼容旧用法。 +> 新的集成建议优先使用 `... for Client.vi` 版本接口。 + +### Register Status for Client.vi +面向指定客户端上下文注册状态订阅。 + +### Unregister Status for Client.vi +面向指定客户端上下文取消状态订阅。 + +### Status Queue.vi +通过队列方式获取状态更新。 + +### ASync-Response Queue.vi +通过队列方式获取异步指令响应。 + +### ASync-Response User Event.vi +通过 User Event 方式获取异步指令响应。 + +### Register Broadcast.vi +注册广播消息订阅。 + +### Unregister Broadcast.vi +取消广播消息订阅。 + +### Register Broadcast for Client.vi +面向指定客户端上下文注册广播订阅。 + +### Unregister Broadcast for Client.vi +面向指定客户端上下文取消广播订阅。