From 6c31ec9a527d256e8bd439242f1ddf925f53d722 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:50:07 +0800 Subject: [PATCH 01/10] Refactor bilingual README for clarity and replace Mermaid diagrams with Excalidraw-based PNG images (#29) * docs: rewrite bilingual README with mermaid diagrams Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/457dc57e-fd6d-4087-a604-3a7a4dd68b7b Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * docs: replace mermaid diagrams with static PNG images Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/a92684df-d1ef-4e7f-801e-473ee3d2911d Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * docs: replace mermaid with png diagrams and add excalidraw sources Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/a92684df-d1ef-4e7f-801e-473ee3d2911d Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * docs: align client diagram alt text with sequence content Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/a92684df-d1ef-4e7f-801e-473ee3d2911d Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * docs: normalize client diagram naming and alt text Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/a92684df-d1ef-4e7f-801e-473ee3d2911d Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * docs: unify client diagram alt text naming Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/a92684df-d1ef-4e7f-801e-473ee3d2911d Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * docs: improve diagram alt text accessibility in both READMEs Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/a92684df-d1ef-4e7f-801e-473ee3d2911d Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --- .doc/csm-tcp-router-client-console.excalidraw | 14 +++ .doc/csm-tcp-router-client-console.png | Bin 0 -> 9476 bytes .doc/csm-tcp-router-command-sets.excalidraw | 17 ++++ .doc/csm-tcp-router-command-sets.png | Bin 0 -> 117195 bytes .doc/csm-tcp-router-framework.excalidraw | 17 ++++ ...image.png => csm-tcp-router-framework.png} | Bin README(zh-cn).md | 95 ++++++++---------- README.md | 93 +++++++---------- 8 files changed, 127 insertions(+), 109 deletions(-) create mode 100644 .doc/csm-tcp-router-client-console.excalidraw create mode 100644 .doc/csm-tcp-router-client-console.png create mode 100644 .doc/csm-tcp-router-command-sets.excalidraw create mode 100644 .doc/csm-tcp-router-command-sets.png create mode 100644 .doc/csm-tcp-router-framework.excalidraw rename .doc/{image.png => csm-tcp-router-framework.png} (100%) 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 0000000000000000000000000000000000000000..e1998f3f018b63e3e8c9ea3d3c74ef597a889843 GIT binary patch literal 9476 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 P5Omqn-lEq0#&7=tilp-r literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4ecf947d176fb53e04dfddd18e7cd28aaddac856 GIT binary patch literal 117195 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 Date: Tue, 21 Apr 2026 23:09:44 +0800 Subject: [PATCH 02/10] Convert `.doc/CSM-TCP-Router.drawio` to Excalidraw source and synced PNG export (#30) * docs: add excalidraw and png converted from drawio Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/8747d072-0055-4877-a383-891476e8e333 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * docs: fix application spelling in converted diagram Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/8747d072-0055-4877-a383-891476e8e333 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --- .doc/CSM-TCP-Router.excalidraw | 1481 ++++++++++++++++++++++++++++++++ .doc/CSM-TCP-Router.png | Bin 0 -> 110059 bytes 2 files changed, 1481 insertions(+) create mode 100644 .doc/CSM-TCP-Router.excalidraw create mode 100644 .doc/CSM-TCP-Router.png diff --git a/.doc/CSM-TCP-Router.excalidraw b/.doc/CSM-TCP-Router.excalidraw new file mode 100644 index 0000000..fb64d1a --- /dev/null +++ b/.doc/CSM-TCP-Router.excalidraw @@ -0,0 +1,1481 @@ +{ + "type": "excalidraw", + "version": 2, + "source": "https://excalidraw.com", + "elements": [ + { + "id": "06097f4ce4a944f5b840", + "type": "rectangle", + "x": -40.0, + "y": 1060.0, + "width": 710.0, + "height": 260.0, + "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": 1, + "versionNonce": 894759244, + "isDeleted": false, + "boundElements": [ + { + "id": "40d09f28b6f5449b8416", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "40d09f28b6f5449b8416", + "type": "text", + "x": 196.68, + "y": 1175.0, + "width": 236.64, + "height": 30.0, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1359914060, + "version": 1, + "versionNonce": 267946355, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "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 + }, + { + "id": "f2931a99db14441d92c2", + "type": "rectangle", + "x": 250.0, + "y": 70.0, + "width": 570.0, + "height": 630.0, + "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": 1, + "versionNonce": 912941967, + "isDeleted": false, + "boundElements": [ + { + "id": "d39d2f1397d84cf29c94", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "d39d2f1397d84cf29c94", + "type": "text", + "x": 367.96000000000004, + "y": 365.0, + "width": 334.08, + "height": 40.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1166131094, + "version": 1, + "versionNonce": 1488794127, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "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 + }, + { + "id": "3a12c9cd83ac49d4af82", + "type": "rectangle", + "x": 320.0, + "y": 120.0, + "width": 450.0, + "height": 340.0, + "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": 1, + "versionNonce": 1007193791, + "isDeleted": false, + "boundElements": [ + { + "id": "96fc065142d741bfab28", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "96fc065142d741bfab28", + "type": "text", + "x": 411.89, + "y": 273.125, + "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": 1, + "versionNonce": 1573432143, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "text": "CSM Module System", + "fontSize": 27, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "3a12c9cd83ac49d4af82", + "originalText": "CSM Module System", + "lineHeight": 1.25, + "autoResize": false + }, + { + "id": "58eb2e1dfd49455083d7", + "type": "rectangle", + "x": 370.0, + "y": 190.0, + "width": 170.0, + "height": 110.0, + "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": 1, + "versionNonce": 1098432173, + "isDeleted": false, + "boundElements": [ + { + "id": "b4cb0e46d8754a6ba28b", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "b4cb0e46d8754a6ba28b", + "type": "text", + "x": 374.0, + "y": 232.5, + "width": 162.0, + "height": 25.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 540651128, + "version": 1, + "versionNonce": 643699448, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "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 + }, + { + "id": "8a5c90e4c9214aff98d8", + "type": "rectangle", + "x": 370.0, + "y": 310.0, + "width": 170.0, + "height": 110.0, + "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": 1, + "versionNonce": 1612758244, + "isDeleted": false, + "boundElements": [ + { + "id": "15dba5756d714bc5be93", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "15dba5756d714bc5be93", + "type": "text", + "x": 374.0, + "y": 352.5, + "width": 162.0, + "height": 25.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1094784021, + "version": 1, + "versionNonce": 1455312304, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "text": "DIO1 (CSM Module2)", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "8a5c90e4c9214aff98d8", + "originalText": "DIO1 (CSM Module2)", + "lineHeight": 1.25, + "autoResize": false + }, + { + "id": "378abd886fa942b5ae6f", + "type": "rectangle", + "x": 550.0, + "y": 190.0, + "width": 170.0, + "height": 110.0, + "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": 1, + "versionNonce": 137789224, + "isDeleted": false, + "boundElements": [ + { + "id": "867f1a5210ac42c7a82d", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "867f1a5210ac42c7a82d", + "type": "text", + "x": 554.0, + "y": 232.5, + "width": 162.0, + "height": 25.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1862328426, + "version": 1, + "versionNonce": 1835601418, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "text": "DIO1 (CSM Module3)", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "378abd886fa942b5ae6f", + "originalText": "DIO1 (CSM Module3)", + "lineHeight": 1.25, + "autoResize": false + }, + { + "id": "adc34df6058e4435acf1", + "type": "rectangle", + "x": 550.0, + "y": 310.0, + "width": 170.0, + "height": 110.0, + "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": 1, + "versionNonce": 1494446171, + "isDeleted": false, + "boundElements": [ + { + "id": "109e949f39d34b6cb355", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "109e949f39d34b6cb355", + "type": "text", + "x": 554.0, + "y": 352.5, + "width": 162.0, + "height": 25.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1205658623, + "version": 1, + "versionNonce": 1551557532, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "text": "Measure (CSM Module4)", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "adc34df6058e4435acf1", + "originalText": "Measure (CSM Module4)", + "lineHeight": 1.25, + "autoResize": false + }, + { + "id": "72a83ed504ed4d0da987", + "type": "rectangle", + "x": 320.0, + "y": 560.0, + "width": 450.0, + "height": 100.0, + "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": 1, + "versionNonce": 1692515392, + "isDeleted": false, + "boundElements": [ + { + "id": "3b93d90e9ba740ec8d2c", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "3b93d90e9ba740ec8d2c", + "type": "text", + "x": 324.0, + "y": 590.0, + "width": 442.0, + "height": 40.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 133198406, + "version": 1, + "versionNonce": 2087443845, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "text": "CSM TCP Router (based on JKI 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 + }, + { + "id": "66d9bb7d39ed49bab977", + "type": "rectangle", + "x": -330.0, + "y": 80.0, + "width": 470.0, + "height": 620.0, + "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": 1, + "versionNonce": 2135205116, + "isDeleted": false, + "boundElements": [ + { + "id": "d61e4a50c7424aafb49c", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "d61e4a50c7424aafb49c", + "type": "text", + "x": -262.03999999999996, + "y": 370.0, + "width": 334.08, + "height": 40.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 531398026, + "version": 1, + "versionNonce": 1296356900, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "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 + }, + { + "id": "d4b5bc67b5e84b86aec2", + "type": "rectangle", + "x": -300.0, + "y": 300.0, + "width": 340.0, + "height": 160.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 451019402, + "version": 1, + "versionNonce": 2020205762, + "isDeleted": false, + "boundElements": [ + { + "id": "e14f5e9cd4674be1bda4", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "e14f5e9cd4674be1bda4", + "type": "text", + "x": -296.0, + "y": 317.5, + "width": 332.0, + "height": 125.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1029267775, + "version": 1, + "versionNonce": 738718317, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "text": "Server Build-in Command\n\nCSM TCP Router 定义的指令,用于管理和显示帮助- List: 列出所有的 CSM 模块\n- List API: 列出CSM 模块的参数\n- Help: 显示CSM模块的帮助(VI Description)", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "d4b5bc67b5e84b86aec2", + "originalText": "Server Build-in Command\n\nCSM TCP Router 定义的指令,用于管理和显示帮助- List: 列出所有的 CSM 模块\n- List API: 列出CSM 模块的参数\n- Help: 显示CSM模块的帮助(VI Description)", + "lineHeight": 1.25, + "autoResize": false + }, + { + "id": "c44e3d5dc3a5490cb039", + "type": "rectangle", + "x": -300.0, + "y": 490.0, + "width": 340.0, + "height": 160.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 286961250, + "version": 1, + "versionNonce": 1108591278, + "isDeleted": false, + "boundElements": [ + { + "id": "ec5f57e035cf45908018", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "ec5f57e035cf45908018", + "type": "text", + "x": -296.0, + "y": 532.5, + "width": 332.0, + "height": 75.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1260928053, + "version": 1, + "versionNonce": 1047655250, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "text": "Server  CSM Command\n由 CSM 模块定义的指令\nCSM 的所有消息,都被转发到 CSM Modules 中执行,因此支持的消息种类,由Server 中的CSM Module System 中的CSM模块决定", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "c44e3d5dc3a5490cb039", + "originalText": "Server  CSM Command\n由 CSM 模块定义的指令\nCSM 的所有消息,都被转发到 CSM Modules 中执行,因此支持的消息种类,由Server 中的CSM Module System 中的CSM模块决定", + "lineHeight": 1.25, + "autoResize": false + }, + { + "id": "68d09aa25b504907b22d", + "type": "rectangle", + "x": -300.0, + "y": 110.0, + "width": 340.0, + "height": 160.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1280667824, + "version": 1, + "versionNonce": 645069776, + "isDeleted": false, + "boundElements": [ + { + "id": "0916692baae84d6a8cb2", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "0916692baae84d6a8cb2", + "type": "text", + "x": -296.0, + "y": 127.5, + "width": 332.0, + "height": 125.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1861740026, + "version": 1, + "versionNonce": 1550368398, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "text": "Client-Def Command\nClient 定义的本地指令,和server无关\n\n- Switch: 切换发送的CSM模块,节省键入CSM模块名称\n- Script: 导入执行 scipt 文本", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "68d09aa25b504907b22d", + "originalText": "Client-Def Command\nClient 定义的本地指令,和server无关\n\n- Switch: 切换发送的CSM模块,节省键入CSM模块名称\n- Script: 导入执行 scipt 文本", + "lineHeight": 1.25, + "autoResize": false + }, + { + "id": "c3d7e032eee9479d92fe", + "type": "rectangle", + "x": -10.0, + "y": 1090.0, + "width": 460.0, + "height": 60.0, + "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": 1, + "versionNonce": 1272916373, + "isDeleted": false, + "boundElements": [ + { + "id": "ba1ba4b5800841ef99ba", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "ba1ba4b5800841ef99ba", + "type": "text", + "x": 80.80000000000001, + "y": 1105.0, + "width": 278.4, + "height": 30.0, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1360334683, + "version": 1, + "versionNonce": 721767282, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "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 + }, + { + "id": "f1fcce1f99dc4c1f8847", + "type": "rectangle", + "x": -10.0, + "y": 1160.0, + "width": 460.0, + "height": 140.0, + "angle": 0, + "strokeColor": "#b85450", + "backgroundColor": "#f8cecc", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 527125112, + "version": 1, + "versionNonce": 1980260540, + "isDeleted": false, + "boundElements": [ + { + "id": "5bab6bdeb62b40228319", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "5bab6bdeb62b40228319", + "type": "text", + "x": -6.0, + "y": 1217.5, + "width": 452.0, + "height": 25.0, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 894462135, + "version": 1, + "versionNonce": 378597871, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "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 + }, + { + "id": "5c296355ff174e0195e2", + "type": "rectangle", + "x": 50.0, + "y": 790.0, + "width": 100.0, + "height": 100.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#D2D3D3", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 425614159, + "version": 1, + "versionNonce": 501325967, + "isDeleted": false, + "boundElements": [ + { + "id": "b543c6d67fdf4c61a3f0", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "b543c6d67fdf4c61a3f0", + "type": "text", + "x": 54.0, + "y": 827.5, + "width": 92.0, + "height": 25.0, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 91453709, + "version": 1, + "versionNonce": 884719392, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "text": "TCP Client", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "5c296355ff174e0195e2", + "originalText": "TCP Client", + "lineHeight": 1.25, + "autoResize": false + }, + { + "id": "b56d185ccff9414282e7", + "type": "rectangle", + "x": 170.0, + "y": 790.0, + "width": 100.0, + "height": 100.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#D2D3D3", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1346551058, + "version": 1, + "versionNonce": 1266930958, + "isDeleted": false, + "boundElements": [ + { + "id": "e1c67e3c14464e33954a", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "e1c67e3c14464e33954a", + "type": "text", + "x": 174.0, + "y": 827.5, + "width": 92.0, + "height": 25.0, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 757773874, + "version": 1, + "versionNonce": 492781324, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "text": "TCP Client", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "b56d185ccff9414282e7", + "originalText": "TCP Client", + "lineHeight": 1.25, + "autoResize": false + }, + { + "id": "c8513775c6fe4da58f55", + "type": "rectangle", + "x": 290.0, + "y": 790.0, + "width": 100.0, + "height": 100.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#D2D3D3", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 757589191, + "version": 1, + "versionNonce": 1529136688, + "isDeleted": false, + "boundElements": [ + { + "id": "709beda0e58b4b5ab6cd", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "709beda0e58b4b5ab6cd", + "type": "text", + "x": 294.0, + "y": 827.5, + "width": 92.0, + "height": 25.0, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1211271590, + "version": 1, + "versionNonce": 534009896, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "text": "TCP Client", + "fontSize": 20, + "fontFamily": 1, + "textAlign": "center", + "verticalAlign": "middle", + "containerId": "c8513775c6fe4da58f55", + "originalText": "TCP Client", + "lineHeight": 1.25, + "autoResize": false + }, + { + "id": "09a22cc2431a4ca7b1e0", + "type": "rectangle", + "x": 370.0, + "y": 910.0, + "width": 430.0, + "height": 120.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "#ffffff", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1821414404, + "version": 1, + "versionNonce": 1294207142, + "isDeleted": false, + "boundElements": [ + { + "id": "627048f5d60843d0a878", + "type": "text" + } + ], + "updated": 0, + "link": null, + "locked": false + }, + { + "id": "627048f5d60843d0a878", + "type": "text", + "x": 374.0, + "y": 920.0, + "width": 422.0, + "height": 100.0, + "angle": 0, + "strokeColor": "#67AB9F", + "backgroundColor": "transparent", + "fillStyle": "hachure", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 215362962, + "version": 1, + "versionNonce": 1839110588, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "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 + }, + { + "id": "4343ca43bb1b46aaab89", + "type": "arrow", + "x": 545.0, + "y": 610.0, + "width": 0.0, + "height": -320.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1789827435, + "version": 1, + "versionNonce": 480108401, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0.0, + -320.0 + ] + ], + "lastCommittedPoint": [ + 0.0, + -320.0 + ], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "c967d534ee594bd8b6f7", + "type": "arrow", + "x": 140.0, + "y": 599.0, + "width": 395.0, + "height": -214.0, + "angle": 0, + "strokeColor": "#1e1e1e", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1930751864, + "version": 1, + "versionNonce": 709055981, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 395.0, + -214.0 + ] + ], + "lastCommittedPoint": [ + 395.0, + -214.0 + ], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "c9b3b417bdbf45d89ce0", + "type": "arrow", + "x": 100.0, + "y": 840.0, + "width": 120.0, + "height": 280.0, + "angle": 0, + "strokeColor": "#97D077", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 4, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1801585414, + "version": 1, + "versionNonce": 373435437, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 120.0, + 280.0 + ] + ], + "lastCommittedPoint": [ + 120.0, + 280.0 + ], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "5f55e1df753d4fb2a6b9", + "type": "arrow", + "x": 220.0, + "y": 840.0, + "width": 0.0, + "height": 280.0, + "angle": 0, + "strokeColor": "#97D077", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 4, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1574452212, + "version": 1, + "versionNonce": 751253604, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + 0.0, + 280.0 + ] + ], + "lastCommittedPoint": [ + 0.0, + 280.0 + ], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + }, + { + "id": "7a7d7908178144bb97dd", + "type": "arrow", + "x": 340.0, + "y": 840.0, + "width": -120.0, + "height": 280.0, + "angle": 0, + "strokeColor": "#97D077", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 4, + "strokeStyle": "dashed", + "roughness": 0, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1366313377, + "version": 1, + "versionNonce": 633510248, + "isDeleted": false, + "boundElements": null, + "updated": 0, + "link": null, + "locked": false, + "points": [ + [ + 0, + 0 + ], + [ + -120.0, + 280.0 + ] + ], + "lastCommittedPoint": [ + -120.0, + 280.0 + ], + "startBinding": null, + "endBinding": null, + "startArrowhead": null, + "endArrowhead": "arrow" + } + ], + "appState": { + "gridSize": null, + "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 0000000000000000000000000000000000000000..37d8fab79a25bcf9768354c274bf6c214fcf6889 GIT binary patch literal 110059 zcmeFaXH=DG)-8%!XqijP958``peP_pHliCuG6)h(fPjc($>s=#P0k=01e7QsL8}l1 z1SLvV$ytelgfkaZhp+qV`<*++`El+T*B?DB)V<&L32V(Y=Ul7r$(=a5aNe4EEG#Sw z8OIJQu(146#KOYL_v>u@WGMFK4VD=!ER4ekl&<)8)mdvOmrqQ7-+TT3=6!)%pRVKI z=4<-qg|AeG`rR4o^?XUu?piytBK3oGbn6Wlg|2W{Ss^svKs~aVW#1#EH5a_4HtyR$ z@Af>tv59Y1Ma_9LgbLd_N3MMPbx6xLCTUoUd*6o2(=Jb*JlTY=reC`G%dA+Ze|wdA zH+%K;Z%@y-#y|bL?3pw5{`zB9dg`fP^MCcl6VtC1zwMhf{X3qGm+5uz>*W5=3#Nbf z{ObSzfmn(E{WW~f-9x8jh^iJ(xMQ&YU?;GZIQBj&iZEyp(Vo z%2K|_hhO*8m*U?%KcPRlWf=>L=t-&0hMiMeCAIp-Yq^ezwqICS9!oVe?7p}Ne^2hd zYqEqOKAP`=lM|1!VZu_{K~9cey=Smo{dqutp8t~nx}|dSc2#3PYh|ShBKZI1Fi7o_ z^r3$yufNJdSG9II=dT>&n`W`dDg7FYuVP^hSDI7gG=pE9orUG{oUFC@)~nYQx6jhE zae5p;O7Z9+Hy{ST9tZkYZ<)*JG4 zp08R++5PR5axVJ+`oX4S7M5Y>sUt({%5u^A;PBJ}4{th`?X-A3%hi(m4LTgNrs7KN-XD)= zu)Ne><>>J}J?6^Pfy=gkeR|iopqVU}8{Vzk-#D!C@thewrmKyeCr)4Q<}F*)(i2uq z-Glv+k{=eZd<)!oMP=Y$Lec)noD2Lc**9*>>m7OUFVV+U%1Lk*i)h3u3l;N!{IgHX ztysUEesJ&JM^$b6&<`KvjvPJ8&B1YGk3pGScXd*EhOO0LZ-cf)XN4Pk&fB+9=W-u* z)TO7m@6`0}eo?YfNJ!0j%^p4xl~@fSAz|VC_wQvMiH}CiUa%^#BIeY}lU}P=uO9vS zfH%>geEX3jN4W6!`E?6Fd z!^-^PVp%ROF5TSV;Na!!*T)>Xvq(SZ{&Eh%)0Zd4tno-P{{H@>KN=tP`)Nx_-T&Iy zxO~HgxaZGz*$eNm2N}BhzKk!`?Z$rJM`ky zE9-=G}Gq$XV0BmzHZ&K;G>UPJRd!HARVby^XJEp z^4m`MK4uRM4dvRj=|I-xM0)bYs;6$;ss=xYy4Q%?y~JyW$LkcdL_do;l_JH={PA_Z zMQ^>PrR8wX1nZzbeKiZG^RK=^c^n)Z@9>69flIafWPJC1?0OX)9j&3MSstsI{xw4D z#fzjQsI{rGNB9_6-Xj{&=^tSpX10Z&F& zR#uut=hpV32%a0SPmfkVKXWLv=Us$OfxAt2QYAYhS~aeyKjXcxkRrZu$&w{f($f6p z3D*5BLhb<#!-@K(TSrDmqx4I|49X&&*bMjVaCDsHQ55Hw_}SKW!I@1=oz9TApi$J9 zKkjsnjr21^UcTHX<1N52s!3K3mUM3F>@-{xqv|>0x6kCDV)zN=IIWDb5XSF&jO#US z%wAaHzT=cBkI3HcJWeq&O>MjHw`_*GBC)$s_`BX>wwCUCQ%Rpw-D%&UC;YLun!CGq ztX=DKkXx@jO4+6}CdJj?|B|p}dkF{Y?AaZ;8yuBRpAKBJ_tHg684V5JSFc_z`~CNG z!b6>LSskGsS|#3l49ha@e;$mEA&xhT$sfycV{gve89?xF1E&}UO zn)=6`#hoLq0hRt@woPqqo}r6a-5L~S#oRc0Z_HWT@%i-)Z()n5oWPi*?mV0)e2l>O zy5E1_;^^q;Cu*&L8>rSdhfc<_g_1aSt1H>ftgLtw`HFmnf+H2f^Q)@V`df=DvDOx& z1Nz3UP-!6C8q{!mVNy*CYtw}N~^xQpf?%XHr z);2cV`S?l(1`GL%mwmDq9r$o4N+I-qNolD?dr7EhT}@e};_}t2BPE^JysN0FP?D7n zj^-=qv=ugQdo2F5i@&O>O24n^U3HdYmcvN%${iXn<&>k8K3)xb;N4vmz$x@|T0R zZr*GgXfG8uZMxf1=(T0eoH_4(_R8M7cW*^Ok&Fm4WY(NT7n<|k;!{#8omTUkH{~r^ zFJ_}efnun)A?2!tgQnyqj_wT=|ps1L1<@?!@A3q{= z-+OOYjrFaqt!-*;4aYupeEagp{Ra>D(~I7{W6YQ_qvO+y;C1WPHFb1ERVC;Z)YogB zIdkTgN`vpJqh6~6PHFYNSGTs8#TUA<)A>L5)=3^$O*vF*xwW;m{>0b_lSfu8Mk6&I zM>`B5Ci-m7eS?yadtt}DHZ#&3#?$a~T^ruP&dN%>F9#!Qe8?xqW|@-1+P#-tJv=-R z-oj0r^Jxp#2wZrKl??aUYfMk6mY*E99--<4Q{c>-Uly(xR-({9*ik-+u-)Ojw?6Ov zmg&>8G|b>>-s)Kg94E)Eg4|5HKJ8|dBovEtGs4<0;gIPnHarauR?e`oC@sboxg{i+ zxsO&vD&|Jji6|;64(4rnbY6&U)hf=#iyzfC+?^Qqb(-<)RlQGA->ARSmMS}Y`vhOx zulKe!b#*<9h~VVdx;6jZyLaZX_^c@Pq$st~(ZNn>CPjtnBtsuRe({D+pH4m)Z}@)t~k4a=i+zQ20#;6aE;a3){0N{qUDB7&h?2+xe?g{|@F#nOK`P*PHNt~Vq8N};!aco}X=zvfg?v(b2!$?eUp2$FclBZm)PczvDqmV%^@pbf{B7g&&Z~BB)*k-S)y1=R?b=(nZnf8@nvMA9d%3%ZA%RWBRdM??J3Bj9SJx2B z?rQxHfd`J*7%S8_xV(scu#(puX%HB}?eXJ4tX1b=rMAaF)?k&Cu&SymqvSg4d~+la zj&0jIo8GyMW=;N-Kx|LgpT8@Xb+2)~DC;l3aPHiB;`{gS>MzdT694h}#!RlMV)F6c z56eCz8gabCMu08fq!AXAfUl;Byk>g974#AX58p^Gy9qH2gM^M3vHx#w1i?Me}W zi`EObWnY`sQJ<0C{^RHd`w%^U>#eHR8d(l?iSC{p8Mea}zCvclJB=Af%#0 zn}5iAvL(s9UE#gI*eT>BLFt-Id)th@d~Q~StT%$@ZE{BSuhg_1MnW^k`lL%uPMtc1 z#I3$gSuf^!4BT+`!jQ|oiUzUxpDfL$a_evV4((nedgXbVWp@;=f_|~T z*!}zW6#&x=DxPs+g$TZl{~Rtho5;+}#Lv6{DWmX%&U`m+Q&Up~q{%XX&xPyu?P?g_ zQy*0TNDrn z7U;t}mu+-B%A-|f@dcF;^1+2OuW_hhO_%@nTOhE;F=SjgL9 z6tPp%Huw;D4S6~UAqg-;6S2X2pUG3CF1d|5Q^)d|p39Q}+0i4}C;;*{cZP;)u)J*e zHd)HNH7qauy`HBrm4$`v{BS|-dHJx!7cXA$ty-`~$n^Zf+^NTAk(!_WHI_vx>^}qT>J($`-Q~OVT8jfz zQ!dptwYNt;l9tjwuIvOlT`yvZ50v`H z2cm%GXD}Fj{rxWNxCwRAOS0JAblqf$qNo-cBt;Uwq_o z-mJ0s>SUwnqioiPks>N@&i#F$HNeq3bpGPS9}W%yvi#Fx4CxUbK8uc;6py z-t-MUpV}3bGk>lwTvf@_KQ!d->?{-HmVm-3W3b{BL9X@b=IzXxvt|u8K9bnJYnL3> zL@<3Tf{2!u*8O|;cCY`57&KTS&8z?3dk-+ZeNg#I4n-hIEyr<-gmZeu{^bB47T-P} zxOMxs_ruoE_ly!$E@fGcjyX9wUJvPA2thntFJeB}p^VC@yvsYlaYD^;?E9V3q3-DI z%27?xi`?`7jwW9_naJ>2Sq&fLZY=cTCm8DM=eGx!b-lRV$#B_#ik`Z3zzIs`-eC`P zSD>6iF+qpz^ob;Zd05Bb;8W)Qz&Mhjc0fx_@E-eX=!p++|O2$V}Cv__)=P@Sg2A^Kx z^Qz*s)`8!4e_f!Dtuvv?fWlwD+ zDiGZNI`jHzKxb-c=}!3m!cbS$C{U_r+7Ju3cIKa^*?@^CRh2y%p`H;pM2_7$vB`ExN1sASWKQw4D5@eNpQl z50k4T_~qr#dCz65M+u_WPbzTa4N33G_`7cL)P6;0R(qtN15gQaCKI(=+_+*78@^2 z#mJmHOZ0Y?rkd%EfBR#xdX__maR2|#1)r8d1Gp2RUCif zG~QS7uf@;V#^?DR7tqDUW%K6EiKky*Kgbn~Q^X8uF7Q|>HNiZt*rcGKFfV3-E6<`u zi>P{j`)EU)s+RSUl{+;);4RsgYxyLgs3?_{hyiM(>{tELBp4M#f|DS3p)hSS%e=+c z_&rT7U;cor!@=0#@FNJQEEIWq`|jQHDaH*d$B#QbWD|=@HET6Msm7Xb(v`>>C7v#W!1Ni)em$ruTiMI5J`(K}p+8Y1gVv+Ll zJ+dQv4a$xIH&g)OP%6E8mF0ncUoiNSjt@aM7OmTNIyvDBpvd{%5!XuEsSrcOmt%%N zm|-)>RC;NIrLx4m_0OD~uni3h>v*>$;8exyR43>?diwOK zE0g&YHFth_xpG~ah2rGIc)xE}TwEM}78WY)VSpdd5%cx+RSG|`9_&kU502+LM30B9Q zN6oZ&Ox|YCg%A6UGe^Se?fTzqgcck5xb)QZ@S#TYMMb6>Xz@JEZfe=ToOUJ>7r*|Q zJwIOlOHWCt^vV*Crvr=m{CCiwFWWg&Z~xYcSgs?ZUuFK0I?qLIr9Wk1`M*rdI1QSd z9_ukb$oX33Z2O&jeDQhtPsM_tFW0esJN{^jw3Iuzde#EFUk{(*zMm{4J`fbKx<>s>qF6Qmef2ZRdZJgR*-VbRuTOtI#CBh%64TeuX`pYXUJ8b**#xzGNH}kji`x!0)r)PDq=2-ZeziIiu=s){+ zpEF-174^!gBuFecH%u)4>| z<02y;b8BmBqwEfpDg?93B7*2J+Ya}1G-PFURKyrc8OyAd zK5>Fd2zCalSmDc`uL61NzhI-{0B4zqHI(&tXsF>K`_uLID4b)gUQXUAdS-EjXce`^% ztR}aFghXgai0Ar8csz6SL{uvw%ehr90HrYd>fc_xc+nMg*TS`eZOtCqMGA4idTpSM z;$OUwmz0!r@8{?qaT^+t9IpA9B4{r!VNC0px9D2J=5OP}4Tc5oI}o@^|GfA6;6SNt zXV4us1v|S}sGh@joJwv3U&5*$hQijq|I6jx`V7#34|B}kKCu{99L4$>mV_{vAzB$W z`iQRvAD`Z$Y6>hGCon1UgRsp&M7#H^5tTbTRKCRN?f z-Q8|IdBlf}9?kUN=V!m(ym|8&6M!K4WAIU*hv_e0=H=uZK}_Bm8x?euAtl8O2nb-; zuYGAk6-AXbP;F{jS`%cMR+N6>TXE4vGo7?~k_<(GS)1D_d>JWh*NWcFy8e0`s3JB};!W(+WK_FOr}!GYuD6YWO&>?#Kx&f9s^*Vp5?y&JTsNPLmLdckb# zD_1TwzIB$8k=e6;@w|D=jEoE{ub#3)o=Cj}t-CAFFGcsW=kvY_1V?jn&Z2dX@7%cq zU`KT)AfW>|)U)mb*l-mCgQ!Jpq8Egd@d`LY#6#;|O91d)zwh$?OP4O0T3EbKUIf-} zw@fRMn&;1lw0C;x`9EO?ZPC`9(x4(KdE4K%M~Jup5bBh$CVns0KCpatphe`&)GkJ#nF@eed}>zS&z3nZ^`AGlQ+~; z7fq6hc9z3^XJ;xeI2d2Pd`YtzG`Qw0C@n2rO3Q;EN24kvod*;tF+=v>Ue8gBH9?&Z zA{D`n6SNKvzX8xR$XL~rCr`Q$Y;YWVfxJ5SWRlAQ4r;q+h>&T`56V zMr`QQxskLDC+`d|rt`xwAMr~i?D%SkGzFEFs%9;P@~CVON275-+S7Y8UDyGqRoK|r zgsuBRpl>BPPC5W~M*>q4FTrNhwn_2xMT9gQT&~AU^xkr5R&)hgS$!xZ(h@aev6Ua2 zz}xocI5XBoTlN|iE(Kf$CuR8M&8>O!=a*icG3&gcp--z#w#{GRUhP-kW+rH}s9#5c(bzw&l&-gjqtBS-!ksUx*XKi-; zZ6IiNE@5He2J216PFv>ScE>q6In8{>pDt*b0$oL8k6}d_it(L{A>?EOcNK&^YU`|hsW7GSrTYq--#NJOOI?ORF2 zO<}7ZU&v_rt|QuA=6R1d5mV;Qs~uZbri3ha92nTd#DvI8RNJ~HH&~Xf`-|B`8jlhm z`*!HoojZnYAAB5p zR=t;mCq5qC(2h_f@3K{9peMbz1JS4=N;&%YhJ0t!QzuSPek1x5VOUS^{lzQR*6qNu zpy}zFgi-N%ytSjlU{svfXaUFXe0W2t~j z!Q0Gxe_hmBxTv4o+BkW6c@x^*Q+Hqdval+BDVWl8E7F_JKl1SZ=x@5clzYpT zLx`WHC?%(YKK96_>qRcDxPAZoy|@m%iSg(DhAHw6a?d()!)nTJk;iRWe)yj2lP5tq zyM)%D=|6o+D-Xv3NuKx-ci5VidSvkOm{WV9D0YD4+WA=2Iel6E0~3$eO!v&h{Lb_y z>nX0W9qtN_lHmr&I2?(*q9URO%c$7C!)9{~lsa8L(&bO3Tw1O^f}PtNZ(G}fc)ttW^(K7qujq3;o?YCBHefXO5pd8$rM(ZBSu9Kt-^kz+FYF+ul z8r@-%HFw^;63F3~EW5g@5`=Aso&oMeU~fxJ3>z#g=k-lLR}U`)_j?0K=xtDMDnLs# zefbiEq-ua8Pb?bLunXV4L2P7<57khfF)J1eM-1WGy!jx}RgPe$9?XxubMs~d2xj#R zo1`~?{6W$(AbvxF!=_D}qM(t&j1oy=b#;<7Fdeq4)RjxoyuU?;QR2D#-0{*AVq#+W zNBW~SEna$k-*5KFa;rRh%nlERJjj7EaAM2XtRWB=3D6Q|Zf#vz^=0}6;{UkI?|N+c zb|q5eNMJUqeJPI`0U*hJDpiAtrO+3p6nQpH7x{|5{PCtk0%k48OHClSrKYBmY@(L` z0_+eJ45f5Sqv#GjJ-u+y)C!9w#-4|WC`y66Iy8=LrT~^~+^`dQT%&oauKIMzXZQwy ziy_W0a{d$cQuAY|0u{Yqz3S{*)|5VFZK>6UUUv}Gr$O=x*YBMWd52#mBma0q&O){c zgW5|APC{_L2X9<<_Wp%y1dhRJ6E=Vx;KB|OSfFwcqNZE<0e%KCsazkJUQi9$+fj)m z2+JOWl98@xwA7`$gG7^KtKrv z1a4sX0JK=IlGWjtQFCsqv~K0Nu$6&?7Jp?7g&UJyGs{6tc0|1Ge9hWiWP5h{O*yl^gE>Mqp>jc#>w`{*;(8 zm}lgblw8$DQRI>kUM&9e8M1s3Fk;(_TsGRjf%khGJY#qbo~67#EjK*nI3=>NAJ*+{kC&-A^e@8TMBA9dCkXO*LvR}MOO zi>DP<4I%>)K2DVQHROE!_>o9KC~Rk{-Gq@~V7IEkiWQa|gc49QDQ_aH3-(Xn} zXoWJD2wa5CW}d-)CSPwtiR6HQt8@PRN&A3XevbNk@fPbP97I+10p;T$mK%P2dV~%; zEE*S;)o{*{sZ_xaAPx^eqiF~0SO)Ev%vKt$2^XvQNMMePjMUAA4nWKd;Y~npWxR5N zhT;417D-0?QH3wb7Nhb*xCU6v2A*7E>c=O3_7bfsc=1ypPJ>rba|&+|=?tMIUi^CO z@48kh{2izUd2E1{3(gxop-huTNrnPd__ZgPCMI$q}iPKSU_{<7|>grlU~;AW6lA>+r_2T|Zg z+~YMgG?Y>!e78bdrLS@h?PD9S%fk(m5r+r z=c-zx9ch8;4ctKiY!B59D2*x+nxM>fYc!{z;FJjv*Tkx?*>(D=piy-Ql|TqLrFiHV zg!fJ`7HfpfBZ~aRe0qOocrZ&Ig}gE990=~J`t&IRk;Mk@kpzrILO&1BaX_#qAZE#7 zK<=_iwHgn(M8w6V`fr_9k0MuK}ysiP<+c6>q8Qv4ZeXhR_p9We_L;vhhKvPO|RTW$@V_7s#>U3j8MyvLg}pCIPfH_`9Ep%$ z*hU!(z&K50*|v{gbIY=&D3>b+O!iy;Ry1?K1zXaar?t{Jc@c}>iykATam zqcr>xyFQE_D~<}agyS}Szqgn}0Spt}F_K(lMZe!9R}h$hC|a{OPVLNRc-~IH7T~cDI1TJ|BS$Vw^G7qIwe`t5htMmH8>@_qOm0M~Hpy6klmUMt z`|_^zoq4#q`wyhs|4b&KoD5l3z4bOgJ{(*%PuQ$qSaz#Z9IHKIgCrw$?AYV<^mG_8 zA9sqDmXxs9d0juh1NA#Yt=9xm6j5xiUvczUOV)pDsJwVI1hib|1KvzI06BTTh4wiD@cE-5X2=SC5Zh(;oAK9b0FXm@!=h?8;ehlnIS0i7g!v+z2GCBIA3ijwefffptZ~-33iQ3OMApGrYXA-AMXVdBttgv; zwoM4KVk1p1WF5ra59t2dSTS$mLS-*L9jL6)JgrWKJHWr58ni8jSCN@$+AI~lw;C!c z*%VPAzpsdy_s^UntJfraqhtjp=^U)5)QJ;B$&=S-;c7ni0^w6k@)oY<*KI=-kV8}l zyV!(KICIBI|X?uSe~;(IGCxKtk# zvk1|YD>u=rM)w3i3*y1?1SO}+Ww)6Xh^*bxi=`x(eGMM)R*?&ulrvllioiMMtW_Rz z&sR;}S^5rsXh7b+zP|X;YLvS=t6S_+a1mSfZGV@O)9fGYP7btAp*B?3>hb6A<(Ltv zCqD6`QKQ)`%McV6lrWi$krtm+sQ@1Kl}X)oB*d{?;vC zcFm5BZx@0XiAL$EJGo!y{MdSfF_wT+ysy4pCzB3hYTh4z9D=h+4*#qiM-)`~>%@<; zmRK6fZe$3(x6NM;@x!v~QxIq^I8~)o%0mPDB>u^b3dc01q3tTqA|5~fjfxTCL&V1i z6caR8FJ8AV5D6?FCQjpwL3dOF+kihvr6z=Vss5Gz_te%(Bz{4SGB4`;)Ra~v9jbS%Ivpk3%WyxleB5D z3*t8ppBag8rfYaXYSmcEk5fA7Q57eliw|980G_>2jZYAVFWm1a|> zhCj|uwzN=og8ZxIdP{nDpL-jD(?BL*n5B=;FK7_UxeT3^88Y|xtrtLQMac)>Ccnl? zZ9946SKlSC)jr^Wn+;TZITon7u`w90fl|)Y)XdBUDhc_wkutcEs~dExYHFepI$2k^ z^3C4gv*&B!74f|yfon^fbjnmNxt2}EnY0Eh&~tkNkTA8IC2wh(m&P0PdAeUqDv)kA zUcI9#Asl;rLHN>`HH0WI*zZ+WgFaw}V5?4w8ZS6IhRRx{RTDXm&H9tOUO1Z3A;d#L%SR-XiLD*L<2%Dn}t+x)K_gsewq zI1t0_Kkz6p(l5(MSIZ9%TnE1rbIP^hFg`jJ*l?<%0lyHf!<>`hubm(A3eQGv2N;v` zt+d`ITgGj4d12JDb(QXM7oUx%wEk%YT(dICiH@Mx3Hq8YAVT6Z6k~0{kHqtnUtRB# z=G+HWSt@d;W}0iqz~VLj+gP&I-!9Ry;uXBK?piVuON@4w*5t%+R==i-<(7@8tsH)K z?lWp40G;=W z>*sBDY@9QKprt0-`}OP7A3v_3?5h*FwG-Y4_*$nR=K$&wU7z2Uhro_0HQMmCuPW94 zr{y(Qy^9hfnv;X5p(sVh#Ke$&@SHl}8LECfTCLe1H}N|L)5qsjW``9o$BPY&TQwd% ztq$zXS-gn*d9b|Oj}FN9x4bWho(}hs63Ud6PulaMc!N$uxqWsyF6ra_`$-m@`t8!! zpW1mWe$)R8%d%%K+hn~Z)n36MSrs;a!Gc27&xQCXX1rfd`M(%dHg0J7uVq%1!)Ms2 zasdux>?cNAnM$Wi1^T_Sz>x3Mco_&gDZR2<(b}VvI&9_VU%}S3v*6CepMU(ko_TC}4~Fl(V{Ao2Jup%s6Mtym(&HAHm(H*^R_QZP+n;=6qU^ z-O5Ceah@?vcc_AtvwX!0_f}IFns%HzRdX@i&c@cZ0H!EN4C>EMAMh_ot)W_nEBDD} zN*D;E5Z%iX^%eK%mxN^J7fVk7C%K#-sp|qigUc3mDkZk+diPsHrx`(oPMrsJR8mcK z7$snr{F`eb;r0r3BnRsSvi-<|JyLI3B(zN}<>Uz`=gzrK?b0h1CJ$RFfprOTI}LtH;?kb5gAWEkYd z&yw-#N9>(HNd-jdVh&wfGtHbqCHlR4@`$a(FEUD~OhUxh;o^?7pL^@k6lcAE?oyC+ zpf@SgkoEP6QQDI&M-8xLpi^Ax9ex@;*zl)mBf?ZA^qVL&K1IL_p;l!3?+Qg>Zh3k6 zfcD*B&gbpfLU|XYNA)1%q=+VZL5q#su?6cnK;A>PL ztk}UPl8h}ThPA|x>kht&U&IleDUAH`YE39;@T}$y*+KK$qgS*J^5BraHKPQ^Z}OqT zXAha3v*zPbl)v&&HsgKME63B)(sWZBx;{KI4G$RzuI+hlV5Ch_WfHXSi$^0sV{1eB zdVZzIJM-q#BU62goCll~Jwd?j6iM%z)Bu%`3VafX(v#ZUI`9i&YAE zUIkQ2eM${q2|7_X7eE31y-N+5F(5G_9VaK~g5_K-23Rf&JTQx#q3|~&cBa94vIiE? z2Qwbw(ih-P8df~}2s@Zkw8{?3)|A9R&_!!hmP2br3@XKrlAx-YYI+2jhFl>~ykbFo zw6w~5UW6}`I$^M#&(dO{rjjWg7zQ7~O_d#(!sN6uIPN0wa#Jmi&==_NB6|U~l+u~? z(6ZrD?R?X`a-4HmN>PIOtW%l_-$RSVLJ-i}Q8!cqLnyyEdxVT(lRt7L2GDe)E0QEg z=e&69mh>12sbArg;+e0rQZ<$3a@Lp0aE|%C!RB8x)GV_^J(!m_P(cJy#bTiC&)y=re&H z<=G#+Yn=Fkf&zxkm7i?%L)VZ0YJEg|d)2hPYVk|rF_GK@97ZDna@A_f4{%)`>QbYF zif>YciS~ZFFvm>%s{`8GM_|du$6sB+a}qwU{agxJ4)%Uxw#hG}<-dM-bb80A!aH=~ z;BVm9KkHim7c?W2H%pEQkNelyDK&+K<^J4j2^SAbO0J*=U-~8I^naj{ID1x4z~ops zAk~z!b%YmuECeG6i#)DlGXkHJBu!uDszrtk*hejU>tldGIuOoEP*;PmR7Orc418cKK57jEUJOAQMr}zbKIOm=mZw=5l25m*8pn`mz_0+&l2N$3 zwF&iPL=_)x+frKmS9fhp=H%C%8!8H9ZD)P^ylJYbJlg;=I^jujoq}c66LN$nhfY5v z7;k>Phs523xYGpdC?LIZ*|HFnvK;^~y16Jr!iaJ~0wg;pDcvZ92p9@gkars}f*iRo zdSFIXsWs|ZZZ(ppb|59?>DdK|a1wNY*vipd|M}X%sW^328J4KQYQwl`w**Yjhj7DG zhkNRj!Bs+3)uIIINVh7jcG2Gq1j?BtsACa|Q8FbsBm(6%bdIp~(Owwt*4` z=jcIo<)=@dY%2;->>~2*Wjz{i1`j>jnIjQEp;Dn3mzvlhHJ5XP5O>?q?9Z>W*HG2P z@~p36a-wpXKqqS8gNF_cf>Js~*ayFW)Odkc^#bfwh&q852gc(u2hKyQ1brf!5-G6i zgRvv20$a!qdjLg5y3|k+1dx8w=+%6Z7deGd0zwMjb#Eh3BNb@A819LINYpyG+x2DN zlZ5M+j9J(gOy8`gY7jh65f-JG)a_yq?ny07yZ(J{?ok{N1w=QQ+?`slBEY^fnADYu zCD60Y0ELMyy(Fmqo{5?{AJG+X1I?c=JX^4 zRgM1P;l1_}=iV-;s%pM8=m9sd5}^0)@pUU#>cLa%JnNZ!&_PzQ!7@c=2$ZM?`VK$7 z%pqShw4f4H%~~6qS6GhV1^`@MLyRwfdSpIDOX^+%po(Akp{^Ut2R{dstPNNQa#z_j zI`1_{+oY|Q2rNL160l&N=Y)wnD?ue>rW~Fg3T}*aiMva-pn2DpK9XDo@N*!kxuK*D zw+kIhNkJDcy2mhG;6Q1(Y?hu4lnYqHXd_U6@rhU=)DnHs@iF)g9^om#GnsSxzJG^p z!af85(Ys{!oH+%jcX)tFBS6KS_;RXr+hA1yIK^g$5RcG3#14SwUWGD2$)n^uq_B(;bo&T)%&Gt$3=B(L$ z_9oCJZ$cy89Fo|9#ECKbfcub8H^USFfINZFJzyX_dp1$`{TidH_#@i(gKlnCYTt+; z1P<$fG$bfO4G!br=0+iA4uS@0i;l}wzzL2ilE~_Qk5dHlNIlgwlzMmeHrVmk=qIB$ zt_-w4cY9HrAjrGYRJ^YN_!zaYK1qxw5EfR48}K784*B1sJ1;0bF7AUrje5;s7L&<^ zKRgi<4#^mZAS4H4KMA-c7(&f~pk_=)^wfERGdY%TnHuTRBz5wnr~W3qS4}H7FGSi> z^Hg%k;Ej|Yc3y8*!Joh>2%z#5;nK_R!NZ3Y0Kgm!AlgDJwOg7K=#h!sbm(>)0yCDy zMYqT|5;9f?<#*J+WeIw+46EzAdtdcG%n_xoYvgNgOhN#>_&}$Ge2yTB%MhfM(JSP< zUPMqkIJJ)(MLubf4ih7KG+P0s0mQIT+=&4+9~q(vlc*bV{=#?Aj(e)&A^%%5yk7t))lO$4=?`RD8zBxK|9)1Z@`P74E2 zhL@!b(4V8O>MC5*C0*S8JAGJ3cuGr4$@r=K?4*2yUB5H>BL=7q9)}^j|Ac%Y-eXb3^Uh9tS+?QZHyAox|m_0Tb?jmYU%Vq6;qDw+w%wPTNV#n?*T_w zI!r4?c#|>+5TNb>VM9b<&#(ovSy{dOP$?J_$PK%RQi*N^xd4cBqh?qXiqy`J+tJJu>~g>4 zm36;4Fb)yJ6sOTsbAH#V^N{P2V-2#e&Y->&^cm15a7ep1Mf#2 zJCM5I%WR6rCiRHW0Hz!|=P*rXEU($v_v<*%+-u+5Lz5otVU zs=dutIm30gFAmLoK!J=BzmNLvVL9$xijt@w{SA!D1cAtH(xBo<)kMBoR2NxDlmZ|i(%ChdEASSS{vO3h=fBZX7}=+E4OCaf-iRw zxrxk*Ju}bX7^$L2$z)|g5$n0*o4<>&MTh4K!M!_698ram`+_?9+YdR? z8$Vhn^b~Gg22kvHyo@RU0(wo=na4}_JEKF#Ab(5$+XYroD5KG{)uR^v<}&Iv#r-5K z7w(3A#fYQMtIyk`-CJRz<`lY9WUjGG2S+#szbE?zgnv&z{)c?8pbAl62v284v+4nf z0zRF5Dp*$((}a$&-gNl5yKU^>+CIE@@7)XW{fxiw>>@t)ty@=|%me^0k(eBjwZ~+S zQMl+6jT^*wXuUd#{#tsK5Rn6cj^mx$Vf#O1tg8I&6izOh-DPGx1Mj4x_B?A>Q_2{P zn;5QgUdP9`^PB(3ns;^vMklOs#@>Ftkm$S0%Dj-b@vbO?`q-6#=c#Zc%L<#rk27=v zam`#HjzJDRFXa;usOV6A3gcR6xJG-CKu%$22pRw`K$~HPIF2=LP^&k?%9Ou2ca%|r z;MEK#5LkWvp4wF4ijRo;eRi@6uO$kAea^3yOG-)E>uhdLKIszxm98On4Qu6mr%RJO zH|i(Plt4BOa%)6e4#-nF<&^3`4RSFk{Ukv%)oA`0r!4_teTs$$i9G;%S9<#^nHlra z#faL_7!bPquIGAOJa}5IJ{Iaf^D2%5)0unx}rL+6Nr3zRkrJ zi)N=%fx6n-<04Y^F`#6q*Zv_c2ZF>&-pn6Q!2^@j4cppOWCgnHUna_}UmPC-1{*1b zr^g_0*>nxDQI$?VLZTECS%jzO>&aDQ+Zh!{34s>lqgFY&xzbFMeJ)m>-M(OiD+^AK zuwK~!aldob4UUsy1$&qyzrObEJ0>ez30ksOEgY4IbiQzY;g5be?s~j(>-btNzWzBk zI*xZis=o1CnVGoq%Mq%H5XXKnLvY}{B-M4o;njffSZ3Rs5spYq+BZcmc68fFj{?5L z#9A@z+_T502fE1>N|i<=Acy;8I%0GI`c~YdI~_3h05^Sytd3TyY0iK>?t+=UCL9ul z*N-0ABa^T(fB4(CT``*m|5z+>-iO+q;Smn&<1>Wsoa>RV|2P&Re!YqAu2lp1+;PX! z5^k$l_WlG`4NHm#>NTtL7qRF#zlH;-GfhKN@snw3J$~HW)D(iFNSZY`a%vI)lf-r-+4%Q(OPj4M{4pTe0 zu38Vjor&Vb#avIy$WqbjL5JGR#cQVE#s0Z+-jaS~r3xENCr>gQA68 zv56OSR-^!GBc@ymiHxj9$Th&=vEYh4C7EKAWBt_2=q?JvQwd|UQ0vfSiBUKuh{6M# z)Q;z%QBPi%6+t<5^$0XdKts@wkqXEw)+n0E>L>==bxUEk!sb;vM+!)r+HQAdL}r@4Ka{< zPQihafu9lu{Bg_>kuBAH1!I>aKnUPIp3*Rj<@DFl&$+y(5`nRVKF(xmQ zi4pRGA(nQu$wiiW>ZzV7cO=hdJLK4Cng;*|Ppt^G7dP5gV4M_k$IX>R;T5qlO55ON zYU&h(LrpdS>_=ocI-UY0I}F}2BhD4ePN9`U~%wQC}_68A;0W`MA@@?hSxjWwspae({->Oh(a zm@)Y0@3QB8CQ#XuAxJ-gRT|A$1601FQBF{#9wjJQUeNLLfjC4$Tte^zlzQtP8R3z3 zDfu?3?5Z&xwU@H@AM<059Vmu%4gEbIDM7=`dw|@mWQD|Ql2#9>s0_1K8Pq6D*qG`r z83o~~4`GK)pwtEoxW|QFe+*dhK#`#wslWlJv??r0=)Tf(A53RkhWMilhCOq%gPUBL zAbLs>&1jwu(o7q9To64>3xjb%VT<%ZU6f=ydAg)$Rl8h z*s!$iB7hZSa?bXmH#naRRg_xGWsoH9iGV9EqEf?6|GMV8RDXH4kMv6yZW5J<$l z!@#>R7f$_z<$oGCIyL{2#r5*kpv0>`HO`+u|HSQxt>hW*{2`~G8>U)b18?%pUiIU* zyKhvM895I*Jw15p(1GbOlCHb#+78L{CnvqRV4JA7Ea}a~;w#_&lGA^?@jCmC=rW(e z8F#K4)6Qn{(H{O)Ez@zxzLIS{BzAppaM`$hSOT0T6GKJnj{e*gfB3tklIPW zM50elh1eX-*T7Xx`Ox`&V|=m^Eea>PtY#eH`6s*U!+*28svO_L;{^@Hyr)(OIr9V( zLpenC;q2KfW5_55)|88bBd6;Mx@lnypq@~uA=k5M%;{9CG3Qw%gbcJnY)1Mgb33`V zsbvg-ypzaHpj2d0<=S!{OQc+3cof=OF*1;@IEaGX&Vk;$&K&gbw=yx|N*>&MKdK9t z((r$5mc0;EIYnDbZ9!-HlKC_s4vdBh2ob^c_$V@EqCG=HLz(*{xe;*F+DI&1Yy2t* z6x&KGH>pHa^LRT369G8<-tXHG;VmrO&_<6kL3Ld@bva#Cxb}X=)y;rjTNzQ>S()XP zY&^1$cp8LDg&(>eEg{FbfJS6xmA#{a+_A6*;kSG|}jWxqI z1EIwbfeIR`oUF5$g2_+-Nq{*hAwV`j`;vWwgL~E@*#3vni1~cVqdoC4))TB%f^<$? zd7Spmy6*{e&#n*7nG7-O)~`3SsKaXyfGeAB6+x224Jsq|DJWWuiTTv!O@SQAoQB-3 zH-uphWquq^P8$}L#xC>H2n$1A$BW2_z;6RXX8u#PtHV~{K2f&IqtF|=w5T>hM`(L9V`t@G|D zpfF9H3OnIvu=}z>cRz1_GBpAPUO?dGJWIwiDu1bu7j2qUHPB!hVn=AoU>q1;C;Zdx z07n=Mp-m`_4A@A!z;9CcD`EQ9UNEKodpip?gu=> z8M(bJx_;vK2i2U{{qQ7QBcrp5`qA6aqo{;p?{m4Fp?IDF(tJ3v3&XgeN{nVti<`On5Rk5-gmsA>og!YhhK?sA+zJa0f} zWGpIhX3sCro>91Flz?2{gGMoGy2O2W0;K^XNq8ABEL*sDQYZw27YY4l%3T620cI-C zB3`sSnoCg22neyi&2?;YZNN{={2CA-o12?MNg4;JqH3QSn_<*$$0?-dXLq{mN9v~T z`m!Bi1~YR40p>_oAcF+hHz~4xQ?nMeali^oqhvCF_K4s{o+$B4%3*H@d+f$uI)KLA z1qqR-(XT;bdwVNtuShsA)bbJ2_g(j;riCi7E>t44&}l&zdz0(A=cB z50I4`*C^o}mYKkCtW~4H?Cq22xX=jQ%O~DnxMh(uMpw+mMsmaUhr0#Eo-ew;uGE1) zuF=X$WS4lr)Wpa%HymUF5w?a%5_AslSzG zG2%tw0WQIJ&*z!+1&Nmrtj)uDp$SI{ZMe%WSX<38aw4)O`K`tYWEyt#!M%x!cmCIj zlj*hzv)fPDC!7&Mxu91-u zhuWe)*{98F);mjK4MQATu~+EW^iaw755d$ocZ@9C+PLO2?stm8HJe7x_^#{Bw@cJh z>&l+)BTMAdK>oTSiafwLpt1TD+ z1bWf)dg}BHOQ~};6we;p;z>6PLIkBLbL7awQqfdN03E!YAq~I%r_K{J$Y6=yH8g0W zCqeP>;hPvzM)Dw{j8}VX1}=bE z0Ha4;(*l0RXfFkW0v2=;d1i1s$Sellv~Wda@%(eUOu(07R-zn|CCwRtRzf2hM)K32 z@57KYnE9C@fXs3rdP)FF+&n#LJ}Qw0q#TL?li!e6>l!SFG`B!O31-1WR2TC!p`gkim|4%yo zc0TY%+=g8y2%?TY_-iVpbIjkeUsi&Do z(zId#vOMfInmpuDbjSqfKBEt>_A4n7c>iXnL<-V5Q2{u*`hVVC64oB40pT@b0;8dE433yH{{w={1oiy4iI` z9z#wgpB?nWYma{XZzpQ~U*IM^u;)oVE^8nI+9jNOAZpLoX8jdNBI8)fXi;+ zk>l7Q`~WR}&O}dPAgdfvEI1H_|BJFO0mriKy1r9XXdq*gS(!3cDw+%-4dxU=h%_L| zR3RcIQ-n%o3Q3X*Aw$X(MbV%_B$OmYsQ$IlJAB{we&6wT9PjZyPY>69-Pd)V``ml& zwbriRZDV7P^vLta%iVu@8KkZv!-)gXOoH8O*QL0o2WdZ9^t^0D8}4mN4q{_&XCb%U z+n-NSMuViBx3(YcyLQ!v_JR4R!ohM_Ml~8$BPb_!>x{hh`XV7BBC_!)sw*QDOn}_# zneqZhxQ|F3@aWvIZs0mjc5)^CF5?IT^Vb8?lvs4A9H~|ylT$j(y*6rV>p}EMQv#s7 zB(p3Kdz0{U*HG(xSb2$DY7yti?q?NJ`Ajg#$<=_i7mQFZb-bc3J38V&i+B_Nlc@DS zIb8L6X9+-fhg#e^dNno*D2q_yJO{fOyvJ;5ThNUbgIY^1=4#pd7dl2!^}2#ug1_I! z{-Hfd?%R@0(yD<#JRO12@iYLB@1*#RV$kML0Wb_=R}vQsyOo%-0p{QJ1B;^m6nt5Z zJxRROsfe zP`413nwTI+pXly@%^w(sl#=LVBv?U=CI_HfW;_2)4tlic%K^M&7ofB-0}AVCMs5n; znkXJtDxgS;F4R4tFRPKZ-8it0*h?kU|In2(?j?*akf%vx{9T%Po(ht>(Du%+|a zCrHeJycbu*?0s^#|I*;E52#FyKwynO^5fpLj;3hXTLE#mQ+a zkWGY3x4ItWq6bJT6p@4jc#NNcm`0@wIO%DqEwSZ1h1cRSM`68wDqlI3&$jiZx?;s<2isTfF2N!a33Tw|@ zbtr@I0UYTdy%4~v2LSjxc8&umCp54Y`bKVnN^6ui;73i6#@NSi{e@3NIYiz(0&g$q9&sD6q&w zOjBO*6WQzk6hcNdczV2C0eFHeIf!Y6oH9z3@_Rg`h_m-PEk}JWd8pfd@J9b74Z2`Q z*!m2kcOujH9;`%izeNg?3^0Zq2GFV48Z8m1iHw5q1!^x~SzklCL{zn-5Q`hcHLKh} zr5v~5Uz~{l9iG~K$HMBvh*x{~2I8n~ijfm~J#x#x$o9zF#sOK!;pnq>p+B$Lr>>v* zssd!T>*!;4LDBx$h@XtE$5|K`dOY9MjraSKpZQFhEJ{iZG_tDXuRC8C#*%>E|JNf1 z-0d2)1l}(V0J3ZdRgWHks_YBZW+oVnVb<9Ne|BwRYQaxuzuQIBBG8!SK(>A>oQGUP z;n@XhL`Ll`!TxxFF$CO^UJDE+WT3#p&f3+*Uq>FdZ9aD!@Sgu!#rUMjlR6FmR58YO zABNtKkP;j~b{xcd= zVwFn6ixxdpYCP?76HjM3d9|TduP7-Is{V;xul;8vD}GGJ_@CjVys9#Q95_@{(j15q z{4XAEMwk35Mv!3Ah!KjGpujtVXfj3vf%FR9A`R+R0hroz=rPe@p+|*|j!u)m{Ux*s zx4^;+B>tv;H-SNgaq*%fNR$8)vNm5(7Se{TNd)wu-$88a>|OzT!wPsL(}93;CcE8h zbjTUzb~I0fu&=AUNI$m22ip*_got4fQP^XPBV;}St*Yh~xFJ%a+T`4WH%0nsa+E|F z0w)}8yc57gV8fYHUmHjjic&geP7NOZ=Zuo7AHRDw-(@d3;5cI9{6l6V_Q9Eb?KoXL z2j3iip15Ha9M~2$*~07pufWpxt(wDxz@hxKz?m;39(?=s>DPFVxsVUqXzz#KYB3JE zS*im#Ngn2JAF;}ZWRh53B!&PV5%LX^DHBT)&|D()jLqMUejR>F4v_ldnqwCD$Lb#|n)BVj1-m#OiE=7VZRmgmHx`kY0lY_A=3o`j zkdG8SWzGL}5=fliEKP)qCJ+vAvdR$p1IjB9N07YnGUnl%qxR56AXH%6Ynl(QpsLDo zf9#iU-@W;`qp@S(zCV(+MYd@nT2(hgqyf}{ApEk|?EC`!DsDdS$?va8IXA~AyfG^7 zfo-#+zHrU7d25n7{~=v`0LF*@oq6nhJ$nk4J3_dz2_1nZ1h#$K%b9v#y1<}eJt^B)p z4@WzqQ~4dPW*eHx1sK|W6nN{`VLGOcM+ zHisA9#WCNMTW=O1JN+*+NK936ijiv|?i$CJ&J{Q?bKRsHo- zudIkj*(d(DM~)p%JYah6?(JXKlt+vY?IT-Kp4Sziqh3M9+>O{Cb^iSM=N+`6$5UoXQp9!c(29irePG(cmyG-P$fnG(w|*WIc)UejT9pQLi+MYiUlo=K(#O`pK& z((LW+#cBB*7%Uf*R9KGiGBg;*!*D5RM4Un3-k$4p1$70@R=~#okhkO~p6Lu?7W34> z&QwcbR*?8OYGv(Hj@pyBQgt%zI%p1$!(sbb_uDoiph(xXB9SLfgYMu3kC04k>JO6d zYUWM9fTVAW>i4s_WHQ`vrQtVZ_lU#;2$}!0bGT0YB@Mf1KRf*&gNF|bRC*sR!Z5qw!@l_% zhwz)HIzvQK6hddZLMB9Tlfw}N!F-QzDKF)BIhE*d6$jy$JPU80iT(sc9U?yfT1ua_ zx?qmC@&`Db)t^?lVo(_9kI(3JgN0#JS?7K%_FKs5(}q|v5C~Wfza(e+#|Y!;NQ|n1 zPN6Gkgz!jKw>U)#k%F35WsQ6FEZg6lis(uOTK|t9jd%gTifvi5+dDmM@5*mDG zUo8{PtV>aU*16`;%#KEfo z$#%8L_j{NAW_$2XG(yBbD>`GL%20gQC65pMfV#Db+>DxZuly!F8CY**AOj^c3X-}! zSvijoairFE4ebgv_W%s)3EOohsX@p@B3$eAg#JxR+Q77fZ?&w)a@4gEhzBtp%}ApN zn#3ZGV`9YKkY_PgRLq%R>{>;wHI3zUR>39`qDd#R33ecy`+X^Ky+gbP0eoL z4K#2hJLv#&$7r(7NyF*3cZ25KS*8u zQpP8$H;BzLhV>)7JO{TrlcF6|=fpjNJ1BX?VI@#=1fo;v9Iv#06{9*rj9Oy&8-BFt zWNSJ=&4Q@-4EIX3H}rz`pb)cLSBCt{9)sPe1B??n5mg3mK?3R<;Bm{YACEe}Gz#n_ zU@U2f`dm-UJ7o^Mzh_?HeiNJ#ZgkuToP+F(2;*>&_pq@syFrA@{|+FUR)OC5KLd|s z$8haS9nAZXq*IBCm#)iJk5#v00_4=Kqg)hC6wG7sBcTu>O4jjsZ`BcBTFU~7h=~H_ zf%HxhwCn%Jkvj%ED)zP%`%+p&85f;8V4fMGWCJcK-F3{MiFMe+_V6+K5Ey%arnUd; zZ|N)JA-MWgkr=m`*Ue{|qNaOAP^7#(v)y~-^Ib7=*fD_nPPwN5AX#|r(q75zIlN}q zK_3+;SD*f0vlN#m*Zm*X-*rkE#`%gix9}Dtdev*;IT7|bpZmZ1bWcCkg?x;lmutP> zuvUm}aJ{Ir=KFH$TA)@cyudV7By~iH@*#hxy z9FNE2&HwH-u3@OQ?V8f>cxKizG@Ld$a+=nI13@Jl(2~bSM@iEQj2EbR^5x5y_ihlN z@)2Uo3YYy@XfRO=e|ZzB`*|id7=WO}+nL$jk9mzf0EC}I8=!mafZYf5uaKJ9Bby>D z6>V%gd4XL{>JcP$2I1&b;=0qoldPR3Ew!ItQJWeve}z6$bLz5Gk5*Vks2kJMHCy-FHZyum7 zYLU?MFP}9CsdOrC9+ofFqebKY`>*out$wdyd4W7wYVeUjiWm1!n0uU8HF^5tA%djlR8j>cqq3JD66n^^XA0VO& z5?OKCh-LgdB6j@ypewpOZ`1@2H!=v`(YP zZ;NK%ZuJctcF$k!t-K2d3V)sI;DxR@?1kN+blN@x>Anm!miHLUQlqtbb8QeL3@;E! z9^zpuif_JPZ(NH+RskQQK=cq&ZJ)*_G0mt$Pc70)j!%I+R$4!Nh`tE}kZWIVqy5Vd z&oq9BWR_%kEo;1wPDRQt@K= zI(VwT(A4v-#+xNBF84>{Wj^k9zfm-BIe%w-)rBSs8G8PsX$v5p#%*J_Wmo^W}u*O4N1AUNgC6>!r|8m@%3d` zH9pSayW+b0Ya{TiGz1|Y-Y3%2Jh11{5h5zm5t!6BJj=)yZEvA%IrHO^(EXyGv5 zA=xpsc4$;y1JZ2V$;?>Hzwp7kTg7j1_S^;rM77bl>3cdcQ%Op7eY+7l%S2-WqLU2z zY0M_mB_YI9qMZa@G|6?GO|CwHQ8n$`l<*udu?VuU-Tmm%%>uH!K<;+%cCX1%+xWK! z(D0c%cl$@%4^7AG?{4i2&OQ*uBh6|XfKy2dBN z$|N%no^KGk=e>;Eiwq^j=N*uR$9D!Ft93g9H{03y7yL6iwP!9K&3_n-;1-`3 z)#O#HjS~tNm(m6eY)^c@7CfC$P7i>mAq5v-U=yaAf;Di;BUGD`IJChbTSfLwQ*j%i z-m8hXwc7S?IN*PAGizAiaG1gwxfK&hABb2${-Fgvf5?RxClztIh;D~7x*q)qBJW@O zQ@#&cYP=VXZ1bUC?dwKW+4%6jduYI^!Mr7(X{*rU2ij9^9<}}!;=1%BSRER$FU?zn z0Jam=wyo%MRh4N+*qBch+4Ao?e%;b6tZ8Vf=l;UJQZ_n=(U%YeS(h5YNSh78IHH6F zD(yFlvykO)idE2nw#5@OPS1kqkE1jdS{-0&`tiMsmiK$sMWgD%Mm$KO8j{fxwE}QK zUeW4%=E;C|lW9~Tn3^fOpew0I`X)}q&KS9MfZ(Y$vH--F zh<$5&Z|x9~Z^mK0|HYHc)-=p8U$G+<_AxYDn@rSBiF7fz3P7mSia@kZ1%62HmIglH z1=2J+o&3Y^HvCozy1#o;BJ`heMTcEn=uHrjJ{2EMm>;PV?*ASp%O4v4hMRN68(|s= z+{6`+5{DK?{lx{{v}XvMMHWJ;s5`5M--e$4!~*ccD#hYksBi;d*<=RVYbVfcW4p0x<;nys#3A(9 zT01(7J{6nl!CQ#!=C;#VUzc(6miC~8egMm~uPr5$;SDY#Au%0rOSEa@ix+&57-%Bu z;o(6^Hyx@n*5%9ZX4{K%A9t}|HK?fdyk26_SRwYK?Zpq}I7acPniYC-L#q-P z8ak`@ZQ&^(Yb9_EZ|%3Uv&*`B_Yq+1@c8(daJb%PYTAmI86`3J@u>**$W*i|-G6-I zx43^aBqKvQ=KT5hDB??jGMomZxEO@LWLa^smmmD_c$wJP*m4}3W&)kK{VI|vCN@?G zi_N|c7}Hmr%$9JiwSRJX5}vf!*{HJy1Ga~u?9Ix_d4K>LAX`qB{nPL0zD0_{VBn}= zVp!$c?%$uOn}=AMS8R&SSY`VpLpXBtwCX9XuS9rG1&jvPql;m~^yHNSRQt^Z9&>O) z@h#oMMa<_@kVehKr{a7NQQ`4l7D;S$G=sG{sSuo%LSV1V`_^70%4tG$b{_vE0?zB087xq@N0 z##|mA@#f{)jaQ^zHvkY2etn|`KMj+YS%PacP8Jrb0;bD?fIq0*8vMI!T*?N|KFktW zUHtm>Jm{|7hx&x@16v_?b?s@KflVswYO}$)yJ3paG4FG^UV}^MfYZ2@@7j<&nYB(LJA6;z$39#sW`yz%*xHrPXHu7(b_LPO?O5!g}t= zui>S%{BS)azI+%{#i#12MB@MF`=Vzaa?nR_`YV*Di3r^>N)DWG-#>#D1&6ZHoFgc! z82y3Hk|-q2kv=;Xm~%#G&kF9|M?|l@^K)4q2UR^pRe}0{#whslF)AJkuoV*GJShie z67&Zub!ue->A(-C2=~u&vo_>d zF)Q69k<~~HLaee7Nb4Cy5Jtt8;2SzEx}JXAww(^9`?hFLVP>B0(F28#9#r|1Qu0 zFa#{};axRu5J$>^igk$3LS~9b%>oVHSHK8X;4&du;3lj?zYvR%OxBv&FJrY#n>ka{ z$w>}uq?=gct*f{=BpxcN&xq*yL~`z%{-2>9;8U=f<}+%_&DH?Fbi^1dBwLK%%h_Il z@b3iS#qGxPQBeV)H(82$K{Zl$Q&E%-*^wF=3vPMQ&=g0+wqnZ`ZNpr?1%=pp3S( zwJ|}~rmn8uf^Ln|p+hImo#O>PBfv++AvDus_eq_|l%r0%8#f-uc$88>gLT085l#%l zJEw&myFqRreG6GCW+`b;Ey9+RYhs?4weX>%PA;O!5KW6=-m&yCtub-BKCdwyCbk5N%lAgHEU~O%zds_sMJ2xS-apg z+OSLPjkjXifSCCeYm(Pv^`t+quQDIEV}^*{5-0Ya+p(z%ZOdJkVgr>mH_t~k^#Ya9 z!|o$}Et42rD_);0Dq4;H3v5;bQL-kY@TcvCfejJ|^}f8mD@#NWoMZ2?PbvDKaV~)V z1Ph2U#fV>qI%nAzzx#lbk{;(G7ej5kdze^#uWvhQQ^s1W{$FSs=DoFp?2*A}zJF|I zrZj6 zvlY63WrDa)BBvJ6?zA>>UuGxwSpaYh25vd-*odIvYHg``?^t>nE z*m<8Py65GsMd-uzA@vat5UUr02BH{+-&4daxarRx9v%)lb4C;I%jeIpR~9ewwumJ< zHkl^WLv-hl z4RAU;zrqycqz@rIqtC^GU<3?JwXledyx0Vk=P6LITyOo@H1cpc31jaNmwKS5h(ww4*X? z^4T05zRk^M@ZXybBSDGh!@*LbqEpb7ogH5X3C(^5S&WsVW!l)tgK84FbVdl?ml}XM zjA)t$msGTo-y?{C*W>GCS=J@{SjqeJpOqa2GsJzNW3M8F`LYI106;z*f_B&-CL z5rcu(=LlD&JAeKZR_8QxH?ig9 z2WH^lp{AxLRC#saajo0 zwjJ9Y3Xw4>6taT(=Z`__)9V(@pYQ+jWm0TROmS`POl+->;P&?ogVRTA62CGR-0QKY z9vzvZ0p-ufL*@}lSqwQ))A7_CMI|!Yl|R=fvmxzcFwnZ51M$*!`y=W4hm9WtbY>I- zm-R*Whw)Rp8OFOtI;?CA#NJY@6fW@qnZ4(^#QZ}-L#Hxgld^~ z>*9c)uyy(e22O?N+E)8ao~~a%af-Z0gk_ymslSpN#EK zKff$+WCC^$80hQ!FDyjn#QVFk`_+%sjs5QzhaG#fRcX`}gtr$39*VF(ryYBnX*VR? z`oqh~s;RQt!#y0F52&$@kE11YGLy?qeETNxGv=|Ocf{Xq2Ch5FdZ$!RTx#yxw2 zfxst#F2bkcE=RM@b4(%?(6fDj*cZ?lAzSlG-|4*A!1wRm`){e^_{W-H#WtfaLvaTi z;(_(euwpmV-V|WStkcB{D~L*wnhPuDz_n`q;147uIN8hIyjcK#%r?yUK`q3NgO+dZ z+yv|<$s1))p3EjRKdjj}9F2vNw{I7s*nHB^5b05e3>}@<$jUmKy?aknYL8dFzRgiv zf14W)&Gkb@%TPlh;f3OlCn7gLH#gPIOuIXB$`8@+lYTD0*xhKNkBmEJ*}l10dkn!f zou_Pq_5r90oBacxQ6)fHxEkeQzR&oXrv}9M48DB}&@s9kXKCh+yEBXb*sYggLl&xm z@Nv_!gvqSHoul{j4c0GIU+#>c6B`}rvg2u-6mA=bO=zWAt(qCXYs{v{Tq1j=vv1zVD4rmU@ z>3#zF(Fed|%}9Q>YIlU0m+z>+XwnTKP9*>8a5(ipq;pi-upCV*(~l$dYi54<@PWtU zQlicg&h%#{^HFOmNG#EDRM$jW5b!9yy}V%xFbI&4H@I6o%Sfg!OP4vQdLiqp&&R~H zKvf}?b6WKF3`Xozd3DiH)!N`n*dKJ5?y0G%p|90j(3c z+#*Vgj{UJn{LatEX9;qb(9>u%2eos(*L)ptxV0-bEQ7`~`PetZ>s#WM^*%)!=Q5}Sx1;wSU!8XD$+L^pfM z{D>)+OifKOf{58?RA`&VV`T%gbkP8idYIAGKZ65z@%;U%LG6li%a%PvPU{Q{PpAl( z5npq?e<~m;Uex@Am^s&nDL=ut7YGUo0Xc*P^i_WxAgs53f%!QDy}$a3tWCD8;sMM) zNVF4>_+!HKJRHj`&?=&j)`9~qfc`5S;pi9!Aa?@M8mGi;0fA7!Ta?;?4P>RiP2sgO}HCbxw<8PCist_?f%J3;H3NUy~aOqNRBwAtfmJlF=lX`N+EK7;GlBpYCC#HiW zazWLLXYKFM#{rLLs=~FcRS^y8GGy*tKa|gkYuoSSl9G~&?X2?Kg{;+h$iiyP60Ld< z`v}pstnHl{QVyZLz-FjFxIS)%5!ZLMloYLFN0|pB6$Fk9g!kp9GIr`oII-40(`{7e z<__Ewtp?PPBmd~+Kj%yI&T;9sd4ga%ih&5oLnp&KZzdkM&`HOwTW14bn#x$u*_41M zKNbA{7zJBa2;ta~R-xFkLT6PO6xP1#E21xLP4&biq!~gI87oD#&3s2Qf1fyU0>27f zd#lMxqm+#3%mX+(8G_HIGHB#$8ZmySS;}g^%tZ4U7yze4|f5`03TzmhHYkY zg)CE5Er+&?YuC;u6js+NAA<%nqoJvZ>{$Kz2Sw9>Pqg^BhJKCs&8Pp2*WOKB4LvnU zxC}AcrxrjrW%Kr2?Z%H#cFAo`z42>`&mB@z)9kIe|MwoCKomRZ5|rS zE#vIB?BBnH8U|V3C9UNUfhy!_a%t=8a`k_d5I%CICPrTEdi%ZZ{j!%wP4*89>|4+j zTy-sbP0pM6DxXV24#$0cCmqG)*C{M4tdq_G;h(l%J^kAYER)&EjM`W zc|%TI>cu>HckoAKXr`C2)-xs7aA%WaDMJ;}gx4R?y@NMw-C z3Ex{TS`pKBuGh%nu(ySA?_?ATe;-9Hmes7YaxlfdXqS&YjZZ0?%xX#Eufi) zDs9xI0sZy2J24_K+j|i-K49FqDs$x|0yJNY@z5lODy*`h;8%!EgY`(xkGZ^zWGg6f1mu?hD(@F z7GVtB54Gjil&cZ`w{PEe+Cl&XfJ!#iit6=9#hyY}rgC(Yk8nA7{!kDH%x$2Yf_&x1 zzy@RU*YOE(DkxO{c^|evK}duX(b3nbrb{$6m1-^OBSorCH6#7kFUe0EzP4rGxuZ#d zDbQ~a*%lo>o;uQEvXu; zd2xDOP-?rf;jQ_SF;lPQJZ!tBqsQ5Vj`L+SvT(@CBOsgx<~aS2sQGiT*LsGWP5lv{ z^e-l51xHJqUWo#Q_1?(_*CXZ0*ORho8Jj#K6C!vkr3X8BjoAnrVASiNY`RE+u9kaqWmV3(2I?(WMzXr3X<^M4gM8xdhWhcw-$M@#1q2eU2*~1g-Em@L<-uk%2Ceq@KkY z%VS*+xEl2|D0Ph<0sbZ1$o?F7@cQ0-yzaaJe;Q>$!(>va-S|k8D(b^$58Msas=ZpVYs}a`U|tnHxPdKZfg0HqK~# z%Pmw@0js1g1MJ&o#jAMu!Q%%y{T7_e?gJk=KqGH$Yx6_;##T+E4ECq=0NY?oQ$X9T z5AIK;j~~y#fXLwC5?{1W5HR(56RkEX&w<7nHiL}0Z5U@Sfr>gm7*gC(mcBW z)uBBX`|4JK4xDSaPMG1T>t0ai0MyI|)HD3z#SpXxs363E!Ve=QD2yzvt@S^B=L{c? zd7p6sF0NBSHl5VIsNoMN=~Z{u=~wOIG}$C|K-+4gNM(j3-j0(l>qd1$XSDi>2xRQY zuc)Z#?(V+0z!N}3X83??GW`bcGF^kX%zdH4^<_%W zyY{iPPffys>VNqos)Dnd`)RfvPVx6BG@)mkOp^w4-U8LB9@rZ(pe)6=*703YVbymB zub37jh5+yVQ13A<9&X-xma`n=-eR-|P->T>7k|z5C$xaox_eV<&^GW_p_WGEJd2lb zJ#8I{l|&Jgg_bm+bymCyjYEf)QMU%o-Noi9h^$VB51V)_P*39j!_)Vco%pI@mTlJ6n+Rq|JU^wvEbhkk;3qj%SXn*#mdDt2L)x zfbuYF-`O<06II$%bT4#48_;T&y~wo;c{k8(H5^cu07>V}nImM+!gZ0$c;2H^qT`uT z4x;xi5Fv-aqU}RooCvIG8@Qc)M@w37*WZuE_Dev*GXz%{s-dY}<`a%XTC=CbP@Z(g)lWYxF=QpRWK~ z5w+;6KJ5QL6PyF;-sOsHf?7ex7h+cvOQ zt>~LaM%r!LpM`1KYTI2xXu-zhioKMy7!6IA8&KTy3|~91j#LV;BL^UE^f;)yR$&A; zXth=qy_D3Xtolc%Bo+0w_v>PW0D8#5W_E}h)Mxh@Z5GF0XdXIrN0+~=RU8DA()WOz z(3D&2zNAuOXrRkL1)qmkyaCNJ;(9r~fjJT&%QA>W+TWfE3RDo-64;5}fLEu(_g@lLkmbBOM2>`Y`c ziYVHm=Lk4K=C&aRppF+sM^*uXcSa&fy-it*g0*&b;(!kWQ2LS5m`~1<0rF^K))}PJ zSKvVV;@0k2h%vsXiDF`6F5`-0iyy6ec+Ydf%~bL>5#Q(`VpTYOsNttOLa8aX*?`o7 z=q44TUjWYXdK(*2yjLqA^z0plqxURl?zc?w0?~U3lppv6}yfLfgOMB`;`PpG4s;zUxR2CtumYmT12d|A-yMY=F8%})2yXDK5P&|{% zz*3~2iS_s?sYXu}EWvx9b ztBCd|CJ!(}6km)eb_Q#`1^J6YrLphmm6KxHtWqz}0r{J|$^y8Hv`VySK-84Qm<)Xe z*eqpof8gEef0>^_iU@`KdpDj))&Z1T3J|xkv5`P`VC}n6^=dk-Lc9!yi3z>-4{lf? z;Mu=;`BGwrFqFu8FC#aP)>%&D2)DcW$aDoXJ<@da0*d4)deEbjZD_uwExl{XC7)jL z03$3#Fq6Zgqm6Bj;J#;bHh6h@TBB@W_+ZyxsX?S8rc&fJwO9Znf=Jg;-4m(}K-~$A zNTU9rspiv`l!c;=5O|+IVgXB)l+;n>hsVSOA^)jlf9n0`w<=Z)*PV4yNm=RhWxIbz zEa8s_pL;2A1OKa67m@XHwBA{+J4urYfa|@79LyIZcyy0OA~>VO*##6)=hzByVc~>q zk&6~SlN5!8KU;?0Vl4*z4dMlB;0uXeK(Zkpou*crv#u#JchAa_tC?Z};2$kLNhYYL zGG;NrMv()TBC6Z490_4~M1&u>bR{)4?9g5K-@GXgLWt33K4~OVGOnk-z`H3NWsJwl zq}{oODWYXO!TR+{SrAT%ZNHCHm>65&7{sb9z_~&o7#biJ3BxCYg0yb2{*c@Cf}&$DS@ErJLjf*`^IizGwPgRkw22ooIwI3)z1VLv*b zi!Tpdlv^6h#p4skWz3JO;i>Y|r2wAMr5qg{h4k)2I9H`@77`L-+a2l6GrRj0rYK1Wz@asn1`&dQ~#C$;Kc~bjSvk{PvP;{^_Sjh#>6(8or6A%v< zp=qD%o0ITeMSXMv2BTEgw9(F zbT^tbHfWDX*s%geF)~>tmjbOw?3ek9*zN1_qhSBKDeZg3h2)D+-NsDwYjP+1!6JT2 zNBB=Pz5oaNf*d4*$eg?^H#b)h0O5;E=p8D1{g8%z1zlmrUNLMK2R|F7sgv#0)sv5_ zEtQIi5PveoaHCDye(SpW&28y>vNyVMdh=+QM4&TqOIByrMd{5)a0~3_A4loNX$x5<=LL;Lf1IQ{wWjoKZQ}{d7Xf=1*dq4 zM%o%erd-jG5|kckb9%ul9@2LYSwI8S>=+;xdyZfOp~ZI`o0i5NN=V2I!g#1&1b5H>#tm6`HTobxplMy*UM)g9NJw?E z9a2mDJ=AJ` zU@Z@puC=fWUhi(j)P}?du}q1CYY4^YmPavd_xaHh_#(U%XZWb3sf*RWvdv zsEWnw0!R3Is4vwKulk6m1W*xb`2a0Zf`R0reG6g#7RBChmR%+)Y+x{h&xAoH}c96$G14_TJYGW+=zKGnj7}URted~tx z16Ca4H?P?>cFg6S&QNw~p9U~ZN&hPKB-#0y7+`ow=j??Iyd@qE2n0-wKnG3^jwoNh zlYdSIrS6q?(p1+_Sh`dLMTZ=w7(!RhP315cF^p-```PBPqz0u=x`{+$`mHEqS1ez- z<;yo18gh}O^`@tQiaSH_{-T{$Xi2uVxBDY0%7N8D;;mYIh+eC*fjCF_{ z6!-AZ{OzHUk+PT?-gUEMGoAn1)Fxog3p zPo}(SJbb+oOT4_yho3|5Cpyl0E3SlyW)6ON_XuC|X`$EQyg^Frhx)9j=KyK~ zJzp@8<=x+4FkN*SzV%U!#Uqgb*;k-q*Xy4s1Tnc*I&1UuxfqdyNK zgp}3SbAEnxy9}E+VC^DA&nOv|8thMAmf9dmL4fs92Oa=J;f~(dn88nz$Ed%c(P)JW zBNw{XT!cD)0j1hHwqS|d#$UU&Pa_Hjz=~vR=VG3Wz9tKe+Dyy92@AqrE4rI5zq*f} zfrAu+$JRU*d}igY`eVWg{eiy70_cy7wX7ok{nS7H-Lm*}Q0nNe7M#E{h&5qcZNkNT z_g<>DWX`WVm-|@M4%YM|ZI<*S8r^S)j2q{5_`R0lJjwb?di-a?GvVqb)qg-NY8LlW z%aq!$mG5I+7ldxn=bgM8Zw-IMw(8NbF~v}#hXsWj2Zzz22#b}5MW7v_t0-mtBtyCI zpu0+Bru6|u$(cWt5qt4RKhhN+9VEGA;xFnaeM&Z1&GpIFI6Nxp@XXNN^-DkP(=0D9 z<~MRj{|zLgAv77;WT2m)icJM07G5uNB(V4o;IO*f>{%>JAQ-BuR`iL>h_FqEC}`;= zL=(ccdyfNLq2n7!6VaH?q!_&Njy9>MqXA*`5?k}2YTiac*U2!YQM@N+z&+7!HcO6;utDJgx zPA)E`pq&Nbt+gG9IkWis!NQE+DJmcZ0{txkP_N@wsQ$q6_>ybvWeUj-!WG4MzMwuD za?VfJHuO#P+$Ef6pSr`M6x`{Fg1e>%S$9xq=p%FhvoL7X3_#V|qffwWav+kf#Gn6G zd&_()La_qO5Ls*>4b#32Sp*a6N!m#k7V*s^uYRkzUN3lnquhDmqaJX8DS-G<%9f+l zAhI-#^>R?1p#EZXpf5U2={ft2#1<_QS+Rn&NkiCH#AhN#0kXMgA3dwi13JBoZ3RY1 zF#=U}{t7=lRoYfVz%J!n+O?n)OUyX0ZkVPub>5K1jZa2*9{h=0{li_wz_jh=hByia z%m-n(NlKYkFTC!LIJiz$O=V%R1|^x{gKjVkd#C@GKzZUs8wE-QLPo)Ouml`~0-Jbd zLzqyGcYGQEugK!X(~#Sq+i@QyK1F7we4k)?4eRfqQrRQP zp_3FJw|>=44tX@!ffIW!DH~~~v_%0QLk@$9@?;yRQGfI1I$*Un7Hbao&SzL69)pq+ z*O!lPhA8wD)-C}(n6L(xuOg1RAm}TvK%Z9XWqp$mXKvQ5TLyEtrJqLofVjy-2z&VO zA%lU6T6x6^D-@h`6c_#au_K@wjRPhI7y(LeGXX?g!IkhvR^cy5Pk_$udt@O!3$BGHB-k_;zjgBZSN^HWf@BaMe1rv=LHu$BlSWuEP7IsCz z4M-5!G0Kw|$epUHp#^4C2>ec70#4yxz_%z;6nt;}vz#J`;MI3y!?$nWV#GX#ogc61 zK^_~xQj2KM!Oa~&tsa1j+xP7PfJc4qjP18ssn(jjyZgdHaFFPw^}f3w3f!4aNm!+n z;K+mcgDo!~wi?(#GtseHfIcN`=@!E8(-$6})0g+xcYcD~TOeZ07^@Jhe|RHo1#chx zzXd46dHQ|QyyE{BqWxYjENEDMfqCg9R8hR1+&VWl2nz}_@Cq;mK@=G&ij65~cj(3` zX=gsV?E0Ev2}FTdsOX(N#k=r^ebGluO>5r06zdfXY+3xr=R9VqQOHxA`C~1>~fZr(G^yY5D>AANE$%xm&kyGc)D`s6;BV=emI?Cpmh9;vEwp=_|+tkF1keuuORTzsX0LjFAu&BWveVC*j+1_{$Gt9sxCz$miOk={( zHXv#i7Vi)Qg&kFBKU-tbQK}CVjr&8rLC)!wogU8g?vX+~K#mq38+#Jv42T()01Eh) z@4rx!CoBWhn?XSqZ5-m0pt>WK74f3;`Xk;Z0uF7`x_2_j)yOtZ0tx-IGMsPVA@RH1 z`lbEH>uXy;v%;)}D#a0E8!fT^0g1E%R03h79C$KSAy{Nq9}^^TT^b1w2Et?{#p+0K z(GCs=F#|u=h9Dl-Y!edMtfj+bl@0?xY3fnJumFR9xbaY^`oLKT1(Zd5Ka{ zFatLD+TXFU)y=Ig4}oto1EDMsPgi)^G9G+!(AK9SW{BOYg|bC@#~pUS!F*7*K(c%i ze?z$)2?k27M9`#DLv}@=EBM$zfw^CbYM!gwFCdL=|3!!M^bvm%=do|GVzb08772J4Fn&<~^ z1sFx#Z{)@$uwjvG(ggee)7wIDy#}`Ooie0hY>fyn3XlH}}6; z$?$=TajEU&$B!p5unGgy(j>rDCR#DDH?&yHG7GS^DSg1vd{=#+xN!Ya_81P1@N_=q zL>I&usZU~uehs@t^~fnIMs_{b>)Q#Wr6W4et%rwUiDZE!g0z@nb(aVU6EZHSaab7O z7+-sK0?8CHK3#4=zB-u!smIm+A7`1hTZnPFM|KDN$KcU3e?pr;zz8bi(u#^{=oay< za7ogY+YpCS9-xRbfc2!SS5cht;>{BTzcELZdlR5wMsaEB`qmNDcnG8dFt>1qZCvYj z96F4}g;sH#5%0BD&c4>WVmah_*#9Mf<*1|wBeedzcK4x!2MzK<$RBf6OzP)vW(qsf z5`HZOEDqXv8+IhC*Fn%^O?#JODN)Ab(Dw?;NFa)=yNkqxB@gh)A$N8|m1{Vy@?0NY z96H6ozr)?u(fBI5joZx=9bx`3XH5gx@7lR6PY7 zg(zhR);i}7IygtB`KRIg}27$&s z$>*WnZFZ!Pc$V!JgM))p89)FvMS7m{pag<0RYX#f6=&eHZG|{67(VMKFGnOpwXq&d zY9jQV8@kxlgY`Ioq4|Net$8kIw2t?Y-*t}Cp~98k4hv`=H)C= z?-4(;9=M??Tx~sk1?*^a1gTKv2vz0}DV%QE?7BdnETfI`VlrWi1`U z$YP3q4?DtWmKke~)D^^IKz48i?kqrDF7wiLK*R(v ziJy4&y21sg z`yTs-lo`bSv0u2d(&;g}`osxM-FbgCfcu;>WibRq!j-}bxyDxP9 z8+4T6nwz9lx%-Z7H4hOx$EC0Yvmj}lD!69D1@Y!WFWUNGiJsT&77>=5!5nByaM+Z< zJ)GR2Px5Rl^jr!90w1haYXBXr)v4(~GI9@pSqMj8nQ!^nyhnd_@a8QvL9VtII-lRL zl^Ru7y(eGp_@f%H!qC`O1ZHYh@lD|uk2)w30K;G9|R-W`Z!Cid5D*f_BZtTway z1=%R}z5hI?jcW|eZ*Zy|aP*3VnmPXQ*;|s7dxTU*07I=uq~k~1i^K%kq1Q-}^k+KM=etwC5uJi7WMGDOD?f)7;U zQmzTiYY%BYtmjJsKz|ZWeU_+gN&HHDzS`euwJVVC9tVFt0hdg*C|pVXa3uD$$N_3b zRbB$)#2y_gcIf>{%7o({-IRy$3JJhdx3}5xT(z$0`i`mepSNvGonLH*{vK6?$S2FN z`_a`RqBG@fV0=|Fi=L7~g#ktII=JvE=?id#FO&Pa2EBqv3k$1;8(oZWEkkkM``O!N zjdZc_N%aa@EWm&(x4#4a0qlu_g1#H=_dj{j=HDw9}h82wGleAG;G`42g%9rI^yH2fi1<+ioy z?s2@xAjudUXz!8t%{~9jPm<;iAyH7fu5t!()mIg$T&fZQ!-gfK)u)D0zMB-br%B`E zD=y&OZ=gMeCm1y|4QDJHr6YO{?@_X=b%3S_!4d870UOF|D~h?cSN-qwmu`*QqsC8N8Id{_R3gs)7}PT~%`@Fta3s zv7_9)q?n%oGmfq)gdS=&$>Bcfk8LoWe)UGcYw3?(EFpP8t_vo9-LPA9o#NPUoTG0> zf09oW(l=GH0+L66a?XsMpP_scaNG6q8&Cv3Nq@B7(+wm9`%GYk^?Y=>RN=qBuH>hP zP@<=S@?`7|qNaJp;yyG0$Bq3vHyc5IhEM-~{GPGPOS1jFyx7=d1UY}~$D=j^v_-DQ z-g1_SKF5{q#c4r8-(jv->1;jQi4W3aF89IR>Qzp>Ps0*9OT4Be z{)zW#G@I^#!+~eweRj;Jr>Xwx@2{^I`}Occ6HnK0rn9jcAk)ti-!65&6@Xr0@P@`8 zuilE{v5))v?X?2s>EjF!O}za2bz>hFr8@C;T&InFT>kNim%pGSRsro=SGI}Uu{}*| zDadus-+%X~=PUf?;l#&D?4s3}>Wy=q9(L;4v2SNsH1TbdY{x%t%fz>Jm_GJ#9rGqG zw_R|0-1rS1o%n)D>pt1i4Lkq6!lbb)7^zHLA&N0}1@6C)bNTr9Ek~tBZzz23*ltQ! znfSi?9J{N~_eeLJ_;*PrTD%-anpd)A|q6GN1VMfQNJ|9Dl!=q0rcaQ5T!oXGV+1KP_eA!Em#TeKX-r6PK$} z{h>f``o4)*Q8m_u2+}(LcXtGiy*WuNXSy4`ys>Z25hXr;w_2U!A7|@3z7pykE>Hy= zR0V8{R(De5*k=1j+?ohm!hhq|c-Z=9+!_yC|A<=?Ve21p%P@WHJtg$}jenfv(um9a zV-MMI;lvl*{>XxMg5ko6m9f2S?D<6P9{&R2565qqd(y-W%8xxCJ&lRS9mO|(IgW{6 z7b_imJE;j1&nq}!Y-Oh=Ozfm*rDK;j;+eR-&iD;hotU^muJOy6PF#+4{L`Z*;;`sg z+!0Rjp7`yxoJwOmBPeM6*T4}ZM>u{IxA2(V*cJCbAA!9$CpfkS9grARp znBnxXU8SD1di-5UD%YN+m1H=vDU7y`?HMfAiOb22U(SJL{Lyev8M}A&xbdZ=x8l** zR}DWj_M}(J$jZ(_-3vT#9l%4%R*{oX^-ReQVQ)wuqTLkT8j(?d1*4;F^yfFU9Huh7 z;p#>>Ca{B3s3@*b>hIyU=F3E9pKuu@lgxA7rsSNebY%VU*Z^Ixkd__;=!kS|X zkx0EjN09&a*1H~FUc12T#1*4GRACZ?rU>Cdm{}w{MO~-gqo=D&2()>?>d@^3H;-Tg zz#x!VTj(n1cN~aJVk(z-hiNnLfd_Y!dhGbz|is+@g|GBmB;I);twI4dG0k8F* zYWvwuygdPWdr7yDk4UfZpYM)U0}7UuNQXPmK88C=h`~Si`hOQr4uoZbvB+a0XgTQr|oQV*+gr z7&J0!{v3t|v(L3_QYd9Z(8!~Vuy0`C?7V)7!daNLBM+`y&VhFep_C&#o3BujFxKkp zb5gm8heuRmGzv5@;+p@*-kXPG{jTf7A1OsbgGN+BGDJzEDN{%?50$Y{ zBoc)(Btub|NrlLiIYWv>sT4{n6d^^K$(Z?f-jCK^!(MCceZ22Je*697?Kt+a`>uGN z&v4)ObzSFmp6B)a`CPDi2+;E-@Q^~6JNP%C;anmsko+)2%SuX@zidT8g+8P7J8}cC zX&}ZHC;;1}&@cnd!G|QAN|tafpFcD&h~d=p2Pj9P{uTK; zta8hRBhbUgc3F?f6;A0exg|gr-0b{OPkw~<$RZM|*ZIiYpF_E5Vrr@px1Ilx`)Or+ zR}jqQkkYob`3*YnUka;*`aD4pQVEl2<;vD(i{DUkz?2J|rd{Z7r`Wr`Xk?*?a}BUf zzQIGPs?55XM%RdR8%Y&4_IMTc;+VysoObrJ1jsFywu1T z1O#GRC*eLlxeGP_VoWbStG1gwLqD(CXNcjw%Tb&t>g%tZHT~KFnib(;LJSfiI0NG` z@?9v2_47}067BRK%^}toeqx2sG&`AKOgw~8Llc<#$Po^BW-NfmglV!V$TT`=FHv@}SQmw5uNH>Fs>ghYWC98rGuNa5K<=p+$&7%7_JKlK&W zAn}nA5)uN-(+6vw{$@IvC6~yFoqw@7eImo8XykLP0 z7~=J%+xBO364clOtuIr+_#}ehsY0|(AVWF|obT4~RE@g~Ac|ijI4(hAU~q!Dt_vuT zze~uq;M~Wx#Nl@IpIq2QeMQt_YKT4MO0f9wYySRA=lFt{LV1w02$e6&)~Bf6x1dXk z-jtfft`mflU3e}tiv{EtVp;+8eQT))sh24{oXzlwRZmjmBuff_Vi{`q%xX(D5_2_9 zC-b37%f(gwcZ}xPfHBXZ2*rT%T5o|i$JWv}II}chcosPfyJI>44k5I@GY0G~C5;bg z=#Mu&#y#OkC$;hAZv+mS@p+U(c)&=SS>WU98BcJE5KAIzfc`jG7SysQ3^JRK^EEPb zOr8hCi+)PrLO%MjqFOX*memsKo%SkP#?%)4hlRGzefeHW)^h~AZ-HFnvZ*f2Dm>zf zu{^~*Dg$ST91Teau?btW$Qu`RWIFxZgV?O$)Kmb>)PQBeM{Ton3NiUxftz_^_W|%{ zq+YB}WL)OHho|V3(PO9Beph8yVaJWq@8WSlBO#}p}HUdvnXK%A~4qgJiOGP?aERjF!^~j%JZlbmw8!0 za5nYA=+HSZ#o7zq8j%2i;Bd^sf*T|_*viO2K0+-R%OM6E=<@*!HS4HOxa{XA3o!-A zpTy#A5;iT+MQh%#=U$aTAa973p#_$XS;s$^)kxu?j(hZi$bml>0OX zIZS~-60W@IF66P#$T^76ak97<>) zOy{2|U0t(-1+XUNL6RSw7y4NybZAQY)50JRqcB+!d>x-})lX7vQ}6>N>WhFFm0xh@i(&a!**N3#a|6!JWz15}NL_mo{ z>nO+7qHSp80zB zqvp#>5y!vVass0$aEpN)j2x`1)$Q~Vbx@$V&z5+9U=aDgparuq$YPB@ILIFug!?Zz zh$h`^1sKLUTM;UJ?LJLDH|!6tqmmUtum_DlIEcm{7z8*7{^y{#PRQJ*CkGFbstcvn zpTbQ&(?}BUVg*{^acc)||F1Z3`I|vLLC3VD zs)`lfTtVlh|BeHv(lh_#jH<)E$8p)^;UeiHTCYV#ID^jq;@D6v6>V8SgGl1cASzhW zS(lmFU*zRAml$;=xcswnkdKc~NmW%|R(6i8oE+&$nMQ)MvIc!7(y_;wGEp>$FJO!# zxEC*xY!H$dI4XiiV*X{tsG49Cs6Hz89#VfgVZ-CBVa&zH*Z|3D^q1{0YAByA`_1{$ zRl~8)KoNr^V%_1~3@RxoV(rxl{*_%s_fA2ROOM9iQltC_4}9=_=%C(nh;ngpff(o| zfO+CINP3M<2Wjm>3de*By#)Kkxb=}7r?jW9$aA~hTgM&8v~S$VyY5l7UP9U$9A4Uy za2A=1#sy4qULeWUG1`nkNWDWUW0qd`pNl*}Ze>%wP6}-5h-Nsf6Z!C7Ou-fH z7Q-=&XzdM2CR#cfO+M{eo^)ai`sAoqng!*-Ov^lwI^;vNhx&C2FD@bUz;(PG@_!qi(^U+ed4d^1wlsTyzpOK^FX~F_4WI z=6K+Gp^;$(F$7-7Cg>YsU+NY-{#U#O|02l(nF8O>;9;-ShWP<^oEZ)JN@iA`BZ2zm zMFz?0@x~xz7laAMCBQwTn+u@iDR?DJG_%@&%)F+|@)Lv{vG$!RO$^#fnt<488{Drm z=%95El9`9QKQEP;?!JkmVf8(9441&&gpQI|R9t@O0r=Tj+1bxfqSM{^!tx>X$?0S3 zyvcLj=1pL*xUHBa%?z6$G>A<>4_}=+3Jnf5a3+>r5hQ!~@`0o$M`W6qads^)cAFAw z1@pp%g5PTi^H@XSsCw-pNYYa(|MZAq2)~dJa)5CmyMG7aE_(axz)(O>Y5^nTQ}pDB zVGmUTr!Kz!`ElS`f6KSgW$?wMmr_i34daCo$e-bc|tHHRS>?6a6<5YeUK)C zM)MAnW|`M)SLKJ`#GY7qLNuoci_SmVe*kuIXjcQ^;K5W2ZJ)#O29>5j)zM9wg?Bj# z`yAdu$=bD$df{sAFtg{88pb2gtu` zKbU<=41CEENxhbF#}`~p2^y?h${i(Nlerh zpaHaK>&zmw)w`A7U8N(<379Fsks=W^Gcpb2IXZ%HKMFv^C)Pcrzl+v8jP69CzLw#S ziHMjF-3@loI_ybw#^nH7kb{AlOM-@^pnUQkz_KUJP)Y_14@EeZt>&k2tVZHmN@YQc z#Jo~e#{aNdSlIReXPdQs1x`}1=%+&?nIM7twGZO-FF4nC9lwI~fpfJLXV8z(IwOlt zqkQE(egz}T;;lyyKb{U{t|}e4E781h$zpczi#cY5qX?HtBEB1Rfj}08BcTrx3#o_e zU;h;r)tbiYfJ?|G!jMFv0Fu_>Q-(OjF|GX~U#54a;E*1BHot%J*_+MiPVAj31@Ux^e%PmIdu2h`Lo>BfK)$Xg4$)g2YL$2 z5lUOWJAZGg3LFJhEmH-t>G-c;SEH73@R(yWGg)!od#_l4mq*k^05kDo1Z>N&R@IRt zdiN>2?L9VDuJuGT!MW19%Sj}CTyT$hS(FlwEJky^jhQc$899(LB>JIkgr2wXl-~0C zCgT*N^Mrq5y2|izvjBdcJKFcA#_G|)whk$chz#);n-0pGqf;i1io^_XGvxu#GBc!i zTl*S5eJVk41>qMq)rfeM2j-udpfGyY1!@z{5Jj9HVHsNg4vkgoSbHQcn7`+!QA63D zs*T(|!7MlNf%ljXYi`Bk*Lr>LOivKY?T&$|<(bl*-<|cJXnRe_b90*$)V4h2pRt`O z`2K394a2OEq97zQH)3UEByp`@Dkw4R#g@#T??v&$0B}lReh5fQ5mV8K_cscw@v-qp6M9S@C%r4MPN%Jh(u;?3g-SpKU66C8A*)ac81n$mD%7KnR04Jwo{27P;g zAodtWn!Qs5PK9L&2x^{>9_^yu7?1kpG{Uu==wtA|2i?5shl~owEk_XpN$mkjX$$-V zV1+=4)Tk(lqlcg41#2yZf`dHVnqJtGN^S}fllS%hWnL#dn5t*`2k~{VQTsc@H#L8~ zynsL|*?8wqcNYIQn8fVmzTVy~NJWXt83_ZSJfYLGRC;Do47^0{Iyw0e0$&h+K4d+} zQpj{WVmAvlD^X|zV0tFXf8}3uqZ9HTXpZqnewI^cH`P@?J?+E(^D+H^uMj3#{sYQQ zp}WEEvzDEw!v=6%O3n6AZ@&x0FS-K09aUfKIQkPJia_wRfyBNpI)}ALGx+MinNnAT zsIhRO0+S|x7lNPVi+31tyb!DWRZ21EY+uh}uq9j8kh+P6&Q$>mo~HgR5XtddDH_86wmIut^f~NT^c;6Uxj_eFZsaJa_JmQ3^x@Ux z>A%CmYpm9k%PKr+{x-bt*OuhV1rr~@`y$eI*zSWJ?_CGeUm~agjZ9~A$=!QhMyK-& z0aufcCw||=S{TqJ;RsSt5z>=UD}XkLl`auZ!9$qx02l6Fjg(F5@zE>t^7BsHQ{qae z_#hp;JCY4m_%n=)zJK!bRzmItFha&-z^0M~X8~agA`Q6&#mcj`Honss@~c^-@j#TE#p`mmT_8uF%mlkOfTyk`N~Yk@3ED2qe@;13+Vl zOn+OpupcQ%4mGMhMx7iDsDKEh_rEIsNwbdiCW3w3s-? z#C540Cq6OWCKSx(C!9DNS9q4AyN%rWC2>CPtZjA7eAtkFJSzCHF+yJW9x{v$WP@$t-}JCgyNL5;)5VCHFPjrn1W&cINowsf!ZQWaRdI)}*E+KIVK0u(>_=J>`$9i|5k%txFMuAtc~OqhK=LAuf5oLIT@x$yD~xGRW|~qpW}LPbo6yp1FwrV zKWfo(kh_i17nrHHyk3z}c?~%@L;MrI79&V9Rs37c_}a??46lVEW!gGS69vp?7tEOP z>V+Gka_|!V26O+CC1#Tso2xT4A#CK{8nD-KQ$q}KBnBQx0vJ4Pm{5nlKq&nMGqm!d zw{y3hZxl@LsnucsB^7j8>E@}%O&vS`Db1SOo$e#-93XoFiQpY3$Mbdbr3?P$z30P* znQFCPL^pG5P7%uJXDc|A$2_%a=m)ft_k)#4Pua~(yFMC)OTTwVgMT#)v#1gXq_hQ$ z13?WXeRfzko8bMB8ALd3{d87@iNNiiVt5m(F|;Es{%6$IKL9@U72*7us=c32&x!wd z-FH`&k0c!n*RZvP_$7y>m8iJ* z3k)LI0)vz^<6PHoxa2Lb0CJJ+Hlg@Bs?=wy5W9{WC=RCd#o&O`-NGGl*>8k0-LPe7AF zhjR1+%+vw>l!8^?4^6JX5#m{D0$URh>CMo^CUR2j6bxLrih=Sej}5^sF9BWSYe&Zw z3@p7c))oQ9;QH*G98v=kItFI{{tjFIYAwrl2vmvtIaY%gFl-|6Te)%pwR(a#(jmg3 zdIq3gq>*LVf!WZjMH*8GJM)k~k+BwdoX<|WRp);8sg8e}x9^a10Z#g)_M=%3Cvx0p zrU5w1<96wUtI#%J_L`}8;M1H`0NS}|nSm}nI$>b4u<{GCj#1QB%iP3u6 z`h377koVJ33|+Y;NbDfTVW1{Qi)UT{v#NNgAU}pIGvvQe)o+??J4lqw5OjWUI)6pQ zuk3;p8n;BT4-X{5WAOGbkSFE{p!c^p{|n@v@YWwW0w*bfxggd7apKJa-JnGh$pFL> zDIaV9b5BA*XoE0hUJw9k>Bm6z2WpJ=p&en;-KKn*J84_>p2`4k5A=3EuoFZM1+w+M zEH|=E@455@C6>EQzy*$IDoCtH-Bb$LhOU+M+~i-(;gGRzl{*%ujH48y+s#KFdVtA~ zTyz~$Wf(ap8|3hzY$rNLia)zd7Zrf{+hUMd)}d?w@k7AS9f8aItvLlN|ENQCF%bU{ zQoMcL+ACzLbkq5z7WSo(@p zXt_4h08@(tP-LN$jYE@ltNF)Ay?-5(uvP4{fM!!wktaMvydI!WZEP9|Z=?j-FB?_n zoX2t^ZD*8Nhf=yWYRZp~|3MY*$hYypJz*g%ZA|BIBR4zQ^mrA`V@~U)L!g0y=_`gaSxDJk)|nDcF=m<6-TXzw+suV|`L-~-qPFcX=Fo;Rs_0NgJv zhm|}BGNqTu1;NZ9#l!|UFM(@8?1q6az7|P{|KL>l^YFj#lhg~||Cikl6-;*#dFRg~2yFfQDWww{ZkP2XQ!y2xKAnfJ%tialp9xJuFlnd3{F&hrhBY zdDVDB2o13iwh@jwu>`HO-^cEIEL_!tF2~bvVuxzupqgQD6PusUiE!ODDj@=OTpe86 z-aYvMABY|prBMmalh)cWbf2L%1ish;M^9j~&%gmE9034D`!Xh>pClUJ_3MjsQF(yl zvL34%AOkfp81ka5OmLCJvLTZ4L5eHK>|ZdgG`cf{NWsvK)qXOK(nZd+Bt?hqES$rh)H*`Y(Lpi z)cEU_@f}E3lBwER^UgAfd>tpL|H-y}T9h38Pa1SU9p8@7?ZJGd6aM|X& zX!8IoAUX>BK2We;6qTNcKrs&W z?1H@q=`bU3Ldc)u+L(jG9Id{EJz;m0AdVwm@3v3I6rIwu&SYkFp&fMbZFElCnABnl zL@OUeN6Mpe!JIh1A0SGtgya^e8`WCXTZqUA-9g6H!--r6j2d)k6mJb)IK(GppPCa? z3pKNH??MNlleCd;NbBGa$3l#u5Xs2$LywAdLe;;^cyNc^)1DnsxHD;!Hvqy*WFD1F(_8HwMBh3`?5G^ht)|G1&r@SU1tT z>saNdbcF$XgxHY@lqND!*_8h{djDuE+x*>OtVKd}+KVLo~!Ly#heK1|cE zfUkKJZ}2B{YaD2*^?XoI5`(x9Og*fK|LfHJobwc?JJY{6R@ec@lmi6a_P*YxCYR z!QYG2Ii!P4q`@SA2Ro8#9d%mj|Kb^QRQw*ma602#4`@eYy82Nxx6vD%OM7$Z8j~`& z{Goa0rgUq+Z9jIVK94`skR?&E^n|QwSybGr)sDbqox?ZQbd3Lu`7`0?Q8LDuE^>Ew zHywErhvjNE`TLiB=6=>Iw?T9*-&OIE^w2@FFCx5_&1XqL_N3Y<*@#D#C$y0!BsN}P~XdA|EG&jXajqhk- zLaz^-!A!eIBpU>EfL0s+m?a3K>KKrf3!Oi@%j9T=M^?N;R5jyEEIBgAldx-$3eaYv zA(sYF^$iInW^Qw!gRkBGz-qu&4^0t|$*CVbJ?X#3d0z&s4Iro^hBl(|(zw?|Jh*W5 zla!1Qi>If~Lrr-X`a%-q-+}N(ULvZ*&AB^HDv!qGqUs`m=*}}gm?5ra0P~30Jdn#n zYNj(?cqbz`S}*v)3JDf?%djDx#DgK>1=tUXXYkzH0&k2+o(c0FY)fJXPo^Nryo$Vc zJZ`?8(3gpVvs_pRA)V!=Ki%P|0IN@Rr)g{; ztJeJn06qKl*X$Kz1cpN-wxD5_AsG(kJ2?VJ#=sc)W^gm{Lf;>{tFc^8bWEJ(uMrT{37jb;qf*=xWd0QUlM_adUWdmM?p+XDE_P{yR%EzM_pM1s^ zVHt)9m!1tAjFuq83cn-1Tj5)9L)u*m7Avhy`_b+OU4sN`_zauiZ7DEp<{^r7q*Y8s zTNB#a+Sm)y;;Oy_s!>4wNVU$qI)_9+wqgbI9U`Pf!}sBtpVyx`c6M}_qS9v?ELQGZ z3#t+(xTfYd;uI^zw))!vj@8*PFadlW{F#MOIc^9`_ABTo;D-o9K+9V>EqLI=gvidF zD^WLK*4c`sOE1Bl72e_ZZNmy^;(4l=L4IPPmU6&$;ClF%2l^dz&tx7HJ%4T!Sxcl+f4lv zi8OI#^+v|)?4fW<1=KQ!f@fR+4f~LU9sHEKk&=C?GJjX&=|5u-dn;V`5ME4ojTi?zhzwD9Ds%4CLX9Tx^aV>dFFY)FpTRrT z>O_oz{tB~8jY6Awyx1cZBE`?4SqlySjJb)=?nLh1Ry2dm0-s^jZube9f@+MEVf%|D z#`@ivJQGDG@6y%*GwQC-+n>JOx*o0I{+OA`eG6^^2w4CGN;qP#*qxJ^n`VhA8s3OOBBgwvF^^5O9=J`I{!0uRP_cQTrh)k+niq4>y_rwUrb97n#7keO_SN=#Wa9gc zU1t*eKNmiHXaek7fHl2F!QA;k3k>h@Cq)z(-X+LcnW`49sg2J-GW`v|LhN^NZtxw9 zCRTUE`W-AoQbk~yCkDmt0zycfCQ;4!qyDX!SH_cb0!UJvaj_e(QB2~AGt$Y#>K*6) z@@ROps#?SGKgSAo{|oUIEFp#!k)fZovFl8A9`d z@gZdE=*t3RK+=2f_Fzgbc?0N?GXzRsdn7ITNpNC`)vm(3+?RGe;2; zT!L`o6$(rVeT?lHOu>30&P=PfpX7gRfpKgg$;2T&Z{aOwf53zAd= z9+G~TKDvwLIeWU|vY#Yo&59~@RzirswTf3#eA`}lL&9G^WK5GC%>k@NBGg#9?Km6S znwMY{@D=|ksNt56wZ=#n?O7_mR?@`_5tZ)M!O9GOF>$Li(#sS;8-iRNYxN~eSIkFB z5yr(IM8i&GH3ozHu8UV*3hMqUoQUC!H-+KbfBW0gXD#>ROjH3HzXJn@Dgj#@x@g5mCUT}&yqMS5 ze-pMsL3_KfHlNnk>b8vt-0c}Ghxi8Wkp{8mSVkKpkd8UVn&%1g^DDKx>P3eQiz|$} zMUzv8TOiR>Ie@)wzTiD<3^GlSq78)Y*sWMo}$Z&BU%yD)Zh7+;xc|zAF!t`iNa?_4RRgmb#4R#8i#Wd^6$PdHB3hdX&du?bzr!9t#6h^dh=D}d zf*BAVM4u)1x~IFl)yGNYxXTQxTh*OU)`(lhJJ>UyAe~nb;e*?`#-qBCbsi?# z5wQ!AaS+W7umIxh{=Mrn!m?=UVOWrmp#q4PNJG|b+qMiwdqht`k{^NYqLA=#KIBNJ zJ-BHTy7UeDHx4!U`Vt&wnWtsS+$D}02F6-DwTTY)vcOfGAr%)G1zx}7 zcLcAT43>V@ZG#GWX;Y!wRDmkDy^gZyQP9P)5DP>%!QtehqHp?%LnnHD!Nq!AIfANG z4)8N!ev-j*x}FyE&;^dgkxp!d?gJe#ZT{KTX8P1~9@BX(_MZ#$hPIkV0FGQpj6IzDVb++`k#J)yg`dod_Wr@w6f8C=@Ja<)NFvvq;SdMeGWyMAjp}XfjWTd^MzJz-LhGsX|ikw4D0*#!@R!N zo0yn*U`mCC#c_kv_L_^{;UoaMc(L-&L_p1JW0N>BgZfY-~g&V9kH zp~~nb)n&w4O*=)e;K)-h{Z6-*2rk_;#%f7fl6VZh&W%J4OM66Qp*qda| zA!2~n08a1fhLldrgeyk!H>EPm#c#K?5T#8Y>N4R)yWC z&%g*6nc88#o@7iws;so`x+UJ-rM{c`s9A1XF=|*W)i`f(9M#e9+}V9pF@3Yi$BVIJ?!S2Z^qw-+ULOj#a1x`CrPr8eo$trtRk_zgk$ncZg~L&Vt;2dMd% zt;1n$;xxh~5<94Jd&~zOUYMC0b^7&9;aIf(%wVj$k5{Mj47*qoKx9pG}B($gt z)sL@QCw*rF<7Rg8+wT$Lv5Mvg#;omvJ1T$nbT-&BJgT48FZYLr_Rd*O%{Hkm2uB!U@{D+M;8iQ?Jq2L)Pkqm%-Ok;F5R8k6kdp>xGv$JG#{51DCoB@vNK z*$UXsML3yJuU*8QjdbP$@F_zs=*3|l{56tKs;^o*{I3iDo@N3Q9vI(+mV+uPp1^GT z=RM&6{P{CMt~vugaIi_(|=vL|5iq~Z#HxQLM``Ufp)}B z(Z$o3pp@v*F?C^_7wY6Efxo{1%Y_$c=%x?J8XHJ23^dtm!&I~c~4?x4l4@9qJkFEm~oNS(%S_@LSzfkH4KvnEqD=dH4BatN>LGQjI z=RpP}MeNa|9Uu1bJv@PDIyC75@E}&Q9L}RcOTTEe;z+~T4}&1dLVz`1w2VH%Qt;!@ z^qI3~mvhZPN~1zwfHjOub_?p#4CAXx^&@k5$ms0>uOhdp)o5XuKVpjhn>A!dy3%oRN^CKBNcMa};M?`iziC|tsMU@oozXyQ}Udt~<$nJXAhBwY!|#0y^3@SpldmR2 z?)|mpA>b=~g@zXt-lNvY$hP+F*}a=dt`t{6Jj!tO^PsmQf(Eb|(250tMxZsHo@f(a zGryTTP^_&|)r3B*Dp7s_sqn-cJkY8L&mYiVo~Je$5mBEH3o3x0svTE}CTDp)q1)IN zPc4VR;%}gXe~hBZh)sf&k{Sb=aYV^Q*vgn@A6{-#y{zN5GUk2-)K~bwFe6C;dS0YW z_zN9|$9T?!u7N*n{4km;#UnZv9@ZPgyWAfRdi)sQJs08L1J?%NpUlF;mL-z}6j*qt z0B{4gyo7@HD5^+e)eWR82yJ)dA;fx^bOVtmcumzIxr;R57B8B?(C5uVoutep{Bk6O z1ma}#e?hQ4ut_n-3cyh6$j{8HWIX`w=Y!HU@ZI-*RG!4e5#OcvQdrB-z)*|XZ?i0o z^E*xG*M#kcWYSB-FE%#zMepa}@NgeQ>2DyaSP5HV>N_iI0F&6^TLs&S)|TJGIwPie zlpR>vq*ElD$%>9J8QM;Q6<$9fzX4!+HSbgWaBt(`7mAO#_7#%nPo3(Ig}6#~X(^Z;6L>6F8HpdVrzJa;1)K|m=+BB}*>m=PkH&Nk%AR1}F zCxz{sJ7M^Rc^&qd*7h@`C0JTYkK)W5FATypBFe_8UlAt0eY;OlQISg7F^z4mMjcEC zj>r*%LjnK*v4lob1`s-tIpN$`u-DB{4|w6MWp*4>1)dbd&%^+%yaFjxO7ESam!_CXWvi$ScMR`kR?hk+JIKWHlf? zA>U2B^T|*w{ha?i6sywiY4s1td9krb)JG50>W#iN>?Y7$K*~h&YjT9+oJ7ar8lDcB zaHYU^D`tl+7w+d032`XYln^ejTwx>)0L-jd2B1-qv`}u(BXJ(taw)7V?BJA%3YD;U zNjDfJ0_ofUc8>)X1nEuC>s$JPiKETfs)VFNgWQ1}{SD*}tfvHYF7l4ARShM-9!yCC zDU2yVd~6i;vJZhxaj5loe->Ns^Z=Mz6O=uNa9+r=WMGMeowiQi zilz{u?UTw6=ZB%w6o!%Um+CH_Rp~+GaH0lGA!06%q_cVL%=Coqs{G3WcR?<{gAhfl z8$dw-Yi!P|7>T`gmXm4ivuP}7$tpHL&WX7Mf*p>2t=c#1=LAZxFF}*?7~j&^>M{fI z*&9gKOhuMkE_Q9n34d6kfEz(f00={XOcy~rtD?XlY*v*)&T@J4<4}`*g1M!^j!VnF z&SHYJihbT+z8n9g`4>Mi@-P37|6$yHWLiujyGwM?wYi*Pg0AC9g)$Yn`s_YI@$ejN&^=Hz^$JqizTe_s}g%Hnv;F?$2l|MVmJ zAAaKh+aGuF2J73^t5<6ytLrhND7I0R{rhVWOnb~s7=ODipr|y3U;Ps&D(C-Mk22ey zE7-j>HlU;Qqn&PsR9di3 zs(vNjaEQ}XBk$%*Ij4Ue>cB-UbK9OFox6*oE}Q?tSd%03Ex)hKps12$rz!8b6=k%~ zT>fIBFUk5pu8CVX!w5@*_9t!m|IJk1Pux$4i^_#d0VnI63ukI3w0MdsrrJ=`Xh8(5_|ejTlTa{tnZVS{d* zD~^WDl_`v~JN17iuKh1PiT|A-{y%cFCA2^++Gr=5?Eb{O`wne%c*diHSkETkwic4QGI2OjNymwv8c z%`#@+B%!s;%+{i!X}ST2BPI%m*0dE`N-F9H@7Ni~FzT`T3w?oDrhMU6d%d2?_uId& zt-a(v8Z%V2vAL#L(m^lRUu<4a;f373@@VOLvqxB1m-pe7c-;7&r9TvAGhrz-qt>Ia zh+%Tv{skAWfsI(L@wwqn{e}a|ng;d-XU$s$c0Apb`dO$-Nv3yk@#;o;tI-0>ZtER~ zhYlYeQ4W@E$r_)MifXsgC@M5AE4-N}F1-J2j`>@Ak)M+vcn|mZIA$$>_XPKb8~Zko zc4G^?JYDkd?hVeBw!XyDwoJ_+#w4+!JnRw@Uem44YgL#5)~w2ja2yHK{Eo>IT=JuVO9QZ@wim)bwDWCC@rz(X5o8 zF}By^&t@L}>9XfzuVcju4vn}MDQ1=_EXP);Ru=OpH%bLekG>JC4-Ze+uuEY)Imcj; z96ghlY`Hy8r^(riDj^^E9!NKQ$oOvfCOH1Xf#Kba+S2!XE9UpTiw%aeL4zi4s?Y5G zllvc@ATWzHVXNvdz3F=-I6nP^!Qwp@SQ9bL8gx~oVV3ij-QPJ^y=PsMky2l?!Prjzshv{Pll4W_7Yx&*$9$s| z*7WS3*u0!PMDB}RD%jIIUR2I+9wtBR9_c`rP`6ktkdDdYq$ab7@~BI$g47 zntIH^r_AE7i|J7GokbQCZD%n@$Egm zJ;z|v_6d30Tl$IW@@qn)jar0UkPL1H?2mt0d;wbwkUoJ@|I`681pal$s^lQV~fKLhWS;@GK# zPu+rNqxF{x_B8Cck@)3m6Z;<4o;6V;oNA8+7X2VA%YmGrF_#UR-`LV#t_l>rlT# z?1gxOt6p4wWUgDo3d+Mtk#_$lE&O}S1;cn;zci-Ay7 z=Z3p-Z(Qn!a(=9-C}t zS@o8G@Yth3-@B})^N4&^42}%Ru{FbICk_~Pc705EqE>1XUTU>HH9upoKyz}j%dq#A zHe)F#UzV^?k*-e_i+e`iD4pK4Z0o_?zJp)H7fVa`teG0I>8Z~$`)OQ-Z6@=Wq|9lX z!a8;doK5U7Gxojdb7VF1Z+2$i&|`Txt@N&l>Dlnr&_8<2YGZ`_-meI3FAvTarjVecINkQqwZ<$GXV#?T=aY; zrdym7w;D^nKh>O;>z1(7_hVyB?42Y#g|`PMGK?nF)~ZE?)n=a8l*)_yb9oY5y*r8* zQJ3iw5)xd-!t~+{uU;0t*>E!9hw7nC0TPp0C}+6OKF)jhi?BDWSxFU}>f!6K?!kMlWvJ5E1j*2YA=40e}_8WLJq5`gsH`pU(b8_IDfbZI!;XpM6=ni_LXzEk0}$ z`SHthxV~L%=YEGudEs92Z317{b6)21w8G_Rlgp83WF3@oU^_-nd3cj|XA;(*MJk== zD`-_tn!!*vs;>(2yE4musk5eSc5$0SdAuM9gbDkvcPwdKo+t2OM`$0migmQ4bKOIH zo4*3>+rGDO>O0S^MwPaNb~$tst@MKaC981*B&tywC2|tv-X>=6E%DtY+?zZT`KK+O zzOE+b?@GzdlF&|RJS&x8bDsMS9~Mmeu=Nr{^g#+Tc#`3l$j$SAi{-k^;fcFqJ3%hz z`AV+oi?7z*z~xks%aIBFWyebHjgNM3&m~=~a4TUX=aFINo=b)MMq@PB-(2bbeeVF4 z=+0h_pD3lj+7p!}+a}tFFW?%v_+o&d^zpES1O>;#gmH=ug6j}`9kvI9`5J2XZovGBwpaG za$Htr9#N}%IL)gjC7`1x>*i1VYFEG}4i?|LhN#p{x+535Z$J7uj4@NwVv zJDgbERy4?F%T;yix0gmBo<`Q}vlsK4U&8N6F1VU@!Kc0Gsf$~1?=0lrzp<^KQ@iKy z?+2Gx(n;GgGOBlddGVvPLi*b>(>b+*E+%*rHL~PQ++HVC;vz4DuZp8R*PV*VfPyG2 zfHc~B`?esKu6C)XDPDvP?M3W*HZN_(+%num9w`pkEEkZM>`_D3ED|=kgDUvZh2$#r z=@+cZs(t@q37!-^SyHDjv(Z_v+AE5SVJDxJw%BWte(C~0I!Y#&AZu(%3zlXrUrb?l z=#z^pSuNY{k>}%qhb&<y!nj0A0; zbFPb*N!!qmm399WEw}f0b>6;8%gY>}RkgIgi`9LgJ8op`QbS+e^+{P}%@|81+d89~ zuo2y1r!T7+=Y`!2Ouj0gboR#zEB9Y~=6gbTl`op8yZ3+ZQ`;5(`j}>RV{G-+lZ}Os zG{c{ernHn1i>b~hr3Hl-8d9RxmXwplXAv@$H3~?nzok?6LHua5{4K+SGv8M5DnC_M;ye}Mrs=4=;?SDm@B1w=6z2%z z_J-SSMsLUH(l*l`G{28E_wv&R=Um?2tRzzvy=Sws%kc>=$%3QJp~I2Nugg_cbe(m3 zAD3m{cq5>6pw5=_$$t0goVBmp22(QfwZfdeQ|;X-!2t3S9leY!FN=Q(pmZBv`y6tf zbYo96Zx{O@o0^b)Q@H$~a!AF`u=7q%3RUg}_l){J8fl$z&-82ww7-3Cw|=(rsLj35 znv;>AGVT@YxljGrzgzQA>^_0f&awSTI*p$bP59&A>~eOwP_?&>C#k$`u=7PqPKuRn z_>Ql7ClnuWHJ?}K?J&@KyQbs2_{eB`>3CN`Q%y$i`g|da9KY(V4t+mVdIW`{PB(^B z&3rb@@AURh(y3_@a<`M8cW>2r<@KWaNaJnhF?KuCj_%0loGP$#WPOA+a_187ts$kh zCWX2^wIa0VCF&BjKHN@Z&gFSEhCRXYr3r0Y720oXh`qC$VLp%YYnC#?%9t7ddQ>I%vt{Ud&9aG?x1^(7zTR;tDwL}$+%q)RINzZ;oYTiq zbZ2*sR+*7*jOB4N13BaTCu1##$Hxs>lezcUnvLcByxuicaPvy$c}Y2!;dwIOKH=-xQF1Xfql*wK+dOSx9VO@%bSm_IQir z^T*X^27XOgakbN~(r?3Fcc(gffj6%-i|bPRqQ}(9&AJ(0lge}adDULN)5$z?S3#0l zVfogau%On^vOYbX^EqyI(?^RO#%^B^x6uvWYo}M!6c;=s)n%SHdoIqLd`wpyJ^4O=s5Rh8LgwRs&Z(q2zMgZp`eS9|-F6f0+lR%)M)qen3{T`YCuLW1>`K!mTOguupR4}-{o6Nf+@+(NLw5S^g3~!% zU!EMs^;#9~xmsnL)5Y5R(ES?+Hl)k9+Pm2XLMeSyt>#Q+woQRN*sC$U8J{vfddVm7 z@pNVk*o=3-J-l4E!=oL^A?2Y{B6@(j&;4}w_{#2ubHiPQLSFMEHO5|BWl(VLOxtad zdHTI$YbIQHl=rDrSf(}P#p-XmJ{QZsjq!KV&_{u)@~{t5j=zt)siefeH?oaXetV*; znn&5SY5l4zAAc2mx#W^Q9-LBG|FLhFpN(0~p!w(U%4l~j-3(0*My>E&Y1_?rh1B$L z-PW)DBz?%(SH+^XuH_p0_*3tIMFFg;3V9>-rRtLbRDXWpFek|&vI z@_u4teYeZ_isFZzKS!_6v+1~^Ryw@uiW&~n)?3SO^ENECIJrlP_2&BvCu{FJK2?9! zFA=oU=vJz+`@~39>!RO_s@9)S4BURo<@ol~Tp-nk%zR%s+ zhcC-zi89yBrA$af+4jDHSw`sd%@7gg-JjKy`Xw4(R*x+$^Eo?GRlW4&<9&LbPam7y z|1sPt;7sqvDIi>D{ADJ2S~_M*eCqqr;(L}-GrX?9!Z$45wJ+{XqWaElZM+QMB`}!t zY0%}{^R3sCsM)p5lp)T0-3pm)_<>12VsBJ+!9lfdcS9@VZ}BBe_v*yr>!a1kmWp=k%e%2_ z=6yD>k-8L-jvDKTk3Tts%lhg%5?x%oKF$x0Z`gk7ij%XRY`B;~@KAZp!qqEpHK&>1 zIXGkA>5B8~`m0?mwrPPp5XMU=+U$DOWHh=r-QxTD&9AZD4Z$~9fJm`LROFUxOtY%fwPT|RW zuik!`I(#le$$3K$7WVEM?aZTfneCs31(#O5;S0GQs@v{Vdd)oMsI}UYQz8vFIN0m#$0b8iN(DtH{RIY@hDNTX7F54a;R-ms;b~y? zlBHph?H`dW^oTSq(?aXImtH`*s=6?*?xV!JBpi%iT9SHvnK%lAA+c6nzYQ7_{uM***ImbR z^xW8j&Y1Lwd6%{vuk=N(8+XjbPLcJFcWl%0D*pI{CJUg=ItDHEwpO>g`Uq+T>O{;AJ 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 C4`Imw literal 0 HcmV?d00001 From aaf5ce4cbd08a8d5e633f50a844f141f242087d7 Mon Sep 17 00:00:00 2001 From: NEVSTOP <8196752+nevstop@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:28:46 +0800 Subject: [PATCH 03/10] 17 python sdk (#32) * feat: add Python pip-publishable SDK for CSM-TCP-Router (#31) * feat: add Python pip-publishable SDK for CSM-TCP-Router Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/4a1ee665-7464-4bd0-8898-0725daef43d5 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * fix: add least-privilege permissions to CI workflow jobs Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/4a1ee665-7464-4bd0-8898-0725daef43d5 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * feat: add asyncio client, Chinese README, and TestPyPI CI stage (v0.2.0) Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/93afe5c4-c917-4b9b-a347-189efb3bf4db Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * fix: address all PR review comments (shared _errors, socket leak, locks, disconnect sentinels, isawaitable, changelog) Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/b0917f8e-c50c-4a63-ae30-a1c245a6d3d7 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * Add bilingual VI API reference docs for CSM-TCP-Router (Server + Client) (#35) * Initial plan * docs: add bilingual VI API documentation for CSM-TCP-Router Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/cacb955d-6c38-4fc7-b30d-9381fbbd06e1 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * docs: move VI API docs under src and align reference format Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/117da425-df26-4ecc-a8c4-ab0e98412e50 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * docs: restore compatibility notes in status API sections Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/117da425-df26-4ecc-a8c4-ab0e98412e50 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --------- Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> --- .github/workflows/Python_SDK.yml | 157 ++++++ SDK/python-package/CHANGELOG.md | 84 +++ SDK/python-package/LICENSE | 21 + SDK/python-package/README.md | 311 +++++++++++ SDK/python-package/README.zh-cn.md | 308 +++++++++++ SDK/python-package/examples/async_usage.py | 97 ++++ SDK/python-package/examples/basic_usage.py | 100 ++++ .../examples/subscribe_status.py | 86 +++ SDK/python-package/pyproject.toml | 85 +++ .../src/csm_tcp_router/__init__.py | 58 ++ .../src/csm_tcp_router/_errors.py | 26 + .../src/csm_tcp_router/_protocol.py | 91 +++ .../src/csm_tcp_router/_transport.py | 176 ++++++ .../src/csm_tcp_router/async_client.py | 523 ++++++++++++++++++ .../src/csm_tcp_router/client.py | 456 +++++++++++++++ .../src/csm_tcp_router/exceptions.py | 45 ++ .../src/csm_tcp_router/models.py | 151 +++++ SDK/python-package/tests/__init__.py | 1 + SDK/python-package/tests/conftest.py | 211 +++++++ SDK/python-package/tests/test_async_client.py | 455 +++++++++++++++ SDK/python-package/tests/test_client.py | 302 ++++++++++ SDK/python-package/tests/test_integration.py | 220 ++++++++ SDK/python-package/tests/test_protocol.py | 154 ++++++ .../VI Description(en-us) - CSM-TCP-Router.md | 95 ++++ .../VI Description(zh-cn) - CSM-TCP-Router.md | 95 ++++ 25 files changed, 4308 insertions(+) create mode 100644 .github/workflows/Python_SDK.yml create mode 100644 SDK/python-package/CHANGELOG.md create mode 100644 SDK/python-package/LICENSE create mode 100644 SDK/python-package/README.md create mode 100644 SDK/python-package/README.zh-cn.md create mode 100644 SDK/python-package/examples/async_usage.py create mode 100644 SDK/python-package/examples/basic_usage.py create mode 100644 SDK/python-package/examples/subscribe_status.py create mode 100644 SDK/python-package/pyproject.toml create mode 100644 SDK/python-package/src/csm_tcp_router/__init__.py create mode 100644 SDK/python-package/src/csm_tcp_router/_errors.py create mode 100644 SDK/python-package/src/csm_tcp_router/_protocol.py create mode 100644 SDK/python-package/src/csm_tcp_router/_transport.py create mode 100644 SDK/python-package/src/csm_tcp_router/async_client.py create mode 100644 SDK/python-package/src/csm_tcp_router/client.py create mode 100644 SDK/python-package/src/csm_tcp_router/exceptions.py create mode 100644 SDK/python-package/src/csm_tcp_router/models.py create mode 100644 SDK/python-package/tests/__init__.py create mode 100644 SDK/python-package/tests/conftest.py create mode 100644 SDK/python-package/tests/test_async_client.py create mode 100644 SDK/python-package/tests/test_client.py create mode 100644 SDK/python-package/tests/test_integration.py create mode 100644 SDK/python-package/tests/test_protocol.py create mode 100644 src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(en-us)/VI Description(en-us) - CSM-TCP-Router.md create mode 100644 src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(zh-cn)/VI Description(zh-cn) - CSM-TCP-Router.md diff --git a/.github/workflows/Python_SDK.yml b/.github/workflows/Python_SDK.yml new file mode 100644 index 0000000..5711af5 --- /dev/null +++ b/.github/workflows/Python_SDK.yml @@ -0,0 +1,157 @@ +name: Python SDK + +on: + push: + paths: + - 'SDK/python-package/**' + tags: + - 'python-sdk-v*' + pull_request: + paths: + - 'SDK/python-package/**' + workflow_dispatch: + +defaults: + run: + working-directory: SDK/python-package + +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 --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; print('Version:', csm_tcp_router.__version__)" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: python-sdk-dist + path: SDK/python-package/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/SDK/python-package/CHANGELOG.md b/SDK/python-package/CHANGELOG.md new file mode 100644 index 0000000..a69cd48 --- /dev/null +++ b/SDK/python-package/CHANGELOG.md @@ -0,0 +1,84 @@ +# 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.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.2.0...HEAD +[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-package/LICENSE b/SDK/python-package/LICENSE new file mode 100644 index 0000000..78e1cd2 --- /dev/null +++ b/SDK/python-package/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-package/README.md b/SDK/python-package/README.md new file mode 100644 index 0000000..1460803 --- /dev/null +++ b/SDK/python-package/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 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 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-package/README.zh-cn.md b/SDK/python-package/README.zh-cn.md new file mode 100644 index 0000000..49f3ef5 --- /dev/null +++ b/SDK/python-package/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 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 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-package/examples/async_usage.py b/SDK/python-package/examples/async_usage.py new file mode 100644 index 0000000..226023c --- /dev/null +++ b/SDK/python-package/examples/async_usage.py @@ -0,0 +1,97 @@ +"""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 import AsyncTcpRouterClient +from csm_tcp_router.models import 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-package/examples/basic_usage.py b/SDK/python-package/examples/basic_usage.py new file mode 100644 index 0000000..3e07d5a --- /dev/null +++ b/SDK/python-package/examples/basic_usage.py @@ -0,0 +1,100 @@ +"""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 import TcpRouterClient +from csm_tcp_router.exceptions import ConnectionError + +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-package/examples/subscribe_status.py b/SDK/python-package/examples/subscribe_status.py new file mode 100644 index 0000000..0442467 --- /dev/null +++ b/SDK/python-package/examples/subscribe_status.py @@ -0,0 +1,86 @@ +"""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 import StatusNotification, TcpRouterClient +from csm_tcp_router.exceptions import ConnectionError, ServerError + +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-package/pyproject.toml b/SDK/python-package/pyproject.toml new file mode 100644 index 0000000..3b77791 --- /dev/null +++ b/SDK/python-package/pyproject.toml @@ -0,0 +1,85 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "csm-tcp-router-client" +version = "0.2.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-package/README.md" +Changelog = "https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/blob/main/SDK/python-package/CHANGELOG.md" + +[tool.hatch.build.targets.wheel] +packages = ["src/csm_tcp_router"] + +# --------------------------------------------------------------------------- +# 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-package/src/csm_tcp_router/__init__.py b/SDK/python-package/src/csm_tcp_router/__init__.py new file mode 100644 index 0000000..1a8d875 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/__init__.py @@ -0,0 +1,58 @@ +"""csm-tcp-router-client – Python client SDK for the CSM-TCP-Router server. + +Sync usage:: + + from csm_tcp_router import TcpRouterClient + + with TcpRouterClient() as client: + client.connect("localhost", 30007) + print(client.list_modules()) + +Async usage:: + + import asyncio + from csm_tcp_router import AsyncTcpRouterClient + + async def main(): + async with AsyncTcpRouterClient() as client: + await client.connect("localhost", 30007) + print(await client.list_modules()) + + asyncio.run(main()) +""" + +from .async_client import AsyncTcpRouterClient +from .client import TcpRouterClient +from .exceptions import ( + ConnectionError, + ProtocolError, + ServerError, + TcpRouterError, + TimeoutError, +) +from .models import ( + AsyncResponse, + CommandResponse, + PacketType, + StatusNotification, +) + +__version__ = "0.2.0" + +__all__ = [ + "TcpRouterClient", + "AsyncTcpRouterClient", + # Exceptions + "TcpRouterError", + "ConnectionError", + "TimeoutError", + "ProtocolError", + "ServerError", + # Models + "PacketType", + "CommandResponse", + "AsyncResponse", + "StatusNotification", + # Version + "__version__", +] diff --git a/SDK/python-package/src/csm_tcp_router/_errors.py b/SDK/python-package/src/csm_tcp_router/_errors.py new file mode 100644 index 0000000..41c6017 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/_errors.py @@ -0,0 +1,26 @@ +"""Shared server-error parsing helper. + +Internal module – nothing is re-exported from the package. +""" + +from __future__ import annotations + +from .exceptions import ServerError +from .models import Packet + +__all__: list = [] # internal; nothing re-exported + + +def _parse_server_error(packet: Packet) -> ServerError: + """Extract code and message from a CSM Error format ``[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) diff --git a/SDK/python-package/src/csm_tcp_router/_protocol.py b/SDK/python-package/src/csm_tcp_router/_protocol.py new file mode 100644 index 0000000..4834ff0 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/_protocol.py @@ -0,0 +1,91 @@ +"""Internal protocol v0 codec. + +Wire format (8-byte header, big-endian):: + + | Data Length (4B) | Version (1B=0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | + ╰────────────────────────── Header (8B) ──────────────────────────╯ + +followed by exactly ``Data Length`` bytes of payload. + +This module is internal; nothing is re-exported from the package. +""" + +from __future__ import annotations + +import struct +from typing import Tuple + +from .exceptions import ProtocolError +from .models import Packet, PacketType + +__all__: list = [] # internal; nothing re-exported + +# Header layout: big-endian uint32 data_len + 4 x uint8 (version, type, flag1, flag2) +_HEADER_FORMAT = "!IBBBB" + +#: Number of bytes in the fixed packet header. +HEADER_SIZE: int = struct.calcsize(_HEADER_FORMAT) # == 8 + +#: Protocol version byte sent in every outgoing packet. +PROTOCOL_VERSION: int = 0x01 + + +def encode_packet( + data: bytes, + packet_type: PacketType, + flag1: int = 0, + flag2: int = 0, +) -> bytes: + """Encode *data* into a complete wire-format packet (header + body). + + :param data: Raw payload bytes. + :param packet_type: :class:`~csm_tcp_router.models.PacketType` for the header. + :param flag1: FLAG1 byte (currently unused; defaults to 0). + :param flag2: FLAG2 byte (currently unused; defaults to 0). + :returns: Concatenated header + payload bytes ready for ``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]: + """Decode an 8-byte header into its constituent fields. + + :returns: ``(data_len, version, type_byte, flag1, flag2)`` + :raises ProtocolError: if *header_bytes* is not exactly :data:`HEADER_SIZE` bytes. + """ + 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: + """Build a :class:`~csm_tcp_router.models.Packet` from raw header + body. + + Unknown packet type bytes are mapped to :attr:`PacketType.INFO` for + forward compatibility (the server may introduce new types in future + protocol revisions). + + :raises ProtocolError: on header size mismatch or body length mismatch. + """ + 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: + # Forward-compatible: treat unknown type as INFO + ptype = PacketType.INFO + return Packet(type=ptype, data=body, version=version, flag1=flag1, flag2=flag2) diff --git a/SDK/python-package/src/csm_tcp_router/_transport.py b/SDK/python-package/src/csm_tcp_router/_transport.py new file mode 100644 index 0000000..2de9938 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/_transport.py @@ -0,0 +1,176 @@ +"""Internal TCP transport layer with a background receive thread. + +This module is internal; nothing is re-exported from the package. +""" + +from __future__ import annotations + +import socket +import struct +import threading +from typing import Callable, Optional + +from ._protocol import HEADER_SIZE, parse_packet +from .exceptions import ConnectionError as RouterConnectionError +from .exceptions import ProtocolError +from .models import Packet + +__all__: list = [] # internal; nothing re-exported + + +class Transport: + """Thread-safe, blocking TCP transport. + + A background daemon thread continuously reads packets from the socket and + dispatches them via *on_packet*. Callers are responsible for keeping + callbacks fast and non-blocking, as they run in the receive thread. + + Lifecycle:: + + t = Transport(on_packet=..., on_disconnect=...) + t.connect("localhost", 30007) + t.send_raw(wire_bytes) + t.disconnect() + """ + + 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 + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + @property + def connected(self) -> bool: + """``True`` while the socket is open and the stop event has not fired.""" + 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: + """Open a TCP connection and start the receive thread. + + :param host: Target hostname or IP address. + :param port: Target TCP port. + :param timeout: Connect timeout in seconds. + :raises ConnectionError: if already connected or if the OS refuses. + """ + 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) # switch to blocking for the recv loop + 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: + """Close the connection and stop the receive thread. + + Safe to call even if not connected. + """ + 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: + """Send *data* atomically. Thread-safe. + + :raises ConnectionError: if not connected or if the send fails. + """ + 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 + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _recv_all(self, size: int) -> bytes: + """Read exactly *size* bytes; returns empty bytes on clean EOF or disconnect.""" + buf = bytearray(size) + view = memoryview(buf) + received = 0 + while received < size: + sock = self._sock # capture locally to avoid TOCTOU race with disconnect() + 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: + """Background thread: read packets and dispatch via callback.""" + try: + while not self._stop_event.is_set(): + header = self._recv_all(HEADER_SIZE) + if not header: + break + + # Extract data_len from the first 4 bytes without full decode + (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: + # Corrupted frame – skip it and keep the loop alive + continue + + self._on_packet(packet) + + except OSError: + pass + finally: + if not self._stop_event.is_set(): + self._stop_event.set() + self._on_disconnect() diff --git a/SDK/python-package/src/csm_tcp_router/async_client.py b/SDK/python-package/src/csm_tcp_router/async_client.py new file mode 100644 index 0000000..de64c00 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/async_client.py @@ -0,0 +1,523 @@ +"""Asyncio-based CSM-TCP-Router client.""" + +from __future__ import annotations + +import asyncio +import inspect +import struct +import time +from typing import Any, Callable, Coroutine, Dict, Optional, Tuple, Union + +from ._errors import _parse_server_error +from ._protocol import HEADER_SIZE, encode_packet, parse_packet +from .exceptions import ConnectionError as RouterConnectionError +from .exceptions import ProtocolError, ServerError +from .exceptions import TimeoutError as RouterTimeoutError +from .models import ( + AsyncResponse, + CommandResponse, + Packet, + PacketType, + StatusNotification, +) + +__all__ = ["AsyncTcpRouterClient"] + +# --------------------------------------------------------------------------- +# Callback type aliases – both plain callables and async coroutines are accepted +# --------------------------------------------------------------------------- + +_SyncStatusCb = Callable[[StatusNotification], None] +_AsyncStatusCb = Callable[[StatusNotification], "Coroutine[Any, Any, None]"] +StatusCallback = Union[_SyncStatusCb, _AsyncStatusCb] + +_SyncAsyncRespCb = Callable[[AsyncResponse], None] +_AsyncAsyncRespCb = Callable[[AsyncResponse], "Coroutine[Any, Any, None]"] +AsyncRespCallback = Union[_SyncAsyncRespCb, _AsyncAsyncRespCb] + +_SubKey = Tuple[str, str] + + +class AsyncTcpRouterClient: + """Asyncio client for a CSM-TCP-Router server. + + Provides the same interface as :class:`~csm_tcp_router.TcpRouterClient` but + as ``async def`` coroutines, suitable for use inside an asyncio event loop. + + **Quickstart**:: + + import asyncio + from csm_tcp_router 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:`~csm_tcp_router.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:`~csm_tcp_router.models.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:`~csm_tcp_router.models.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[StatusCallback]] = {} + 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. + + :param host: Server hostname or IP address. + :param port: Server TCP port (the reference app defaults to 30007). + :param timeout: Connection timeout in seconds. + :raises ConnectionError: if already connected or the OS refuses. + """ + 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:`~csm_tcp_router.exceptions.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. + + :param host: Server hostname or IP address. + :param port: Server TCP port. + :param timeout: Maximum time to wait in seconds. + :param retry_interval: Pause between retries in seconds. + :returns: ``True`` when the server is reachable; ``False`` on timeout. + """ + 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. + + Use the CSM synchronous suffix ``-@`` in *command*:: + + resp = await client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + + :param command: CSM command string. + :param timeout: Seconds to wait for the ``resp`` packet. + :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) + 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. + + Use the CSM async suffix ``->`` in *command*:: + + await client.post("API: Start Sampling -> DAQmx") + + :param command: CSM command string including the ``->`` suffix. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :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) + 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. + + Use the CSM no-reply suffix ``->|`` in *command*:: + + await client.post_no_reply("API: Reset ->| DAQmx") + + :param command: CSM command string including the ``->|`` suffix. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :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) + 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[StatusCallback] = None, + timeout: float = 5.0, + ) -> None: + """Subscribe to a CSM module's status broadcast. + + Sends ``"@ ->"`` and awaits the + ``cmd-resp`` handshake. Once subscribed, + :class:`~csm_tcp_router.models.StatusNotification` objects will be: + + * delivered to *callback* (if provided – sync or async both accepted), and + * added to :attr:`status_queue`. + + :param status_name: Name of the status (e.g. ``"Status"``). + :param module_name: Name of the CSM module (e.g. ``"AI"``). + :param callback: Optional callable or coroutine invoked per notification. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the subscription. + """ + # 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. + + :param status_name: Name of the subscribed status. + :param module_name: Name of the CSM module. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the request. + """ + 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. + + The callback is matched by *original_command* (the command text + echoed in the ``async-resp`` payload after the `` <- `` separator). + + Callbacks may be either a plain callable or an ``async def`` coroutine. + + :param original_command: The command text echoed in the ``async-resp``. + :param callback: Callable or coroutine receiving an + :class:`~csm_tcp_router.models.AsyncResponse`. + """ + 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-package/src/csm_tcp_router/client.py b/SDK/python-package/src/csm_tcp_router/client.py new file mode 100644 index 0000000..2f870d0 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/client.py @@ -0,0 +1,456 @@ +"""High-level CSM-TCP-Router client.""" + +from __future__ import annotations + +import queue +import threading +import time +from typing import Callable, Dict, Optional, Tuple + +from ._errors import _parse_server_error +from ._protocol import encode_packet +from ._transport import Transport +from .exceptions import ConnectionError as RouterConnectionError +from .exceptions import ServerError +from .exceptions import TimeoutError as RouterTimeoutError +from .models import ( + AsyncResponse, + CommandResponse, + Packet, + PacketType, + StatusNotification, +) + +__all__ = ["TcpRouterClient"] + +# Type aliases +_SubKey = Tuple[str, str] +StatusCallback = Callable[[StatusNotification], None] +AsyncCallback = Callable[[AsyncResponse], None] + +# Items held in the internal queues are either Packet or Exception instances. +_QueueItem = object + + +class TcpRouterClient: + """Python client for a CSM-TCP-Router server. + + This class mirrors the LabVIEW ClientAPI VIs and speaks the + CSM-TCP-Router protocol v0. It is thread-safe in that its internal + state is protected by locks; however, the protocol allows at most one + in-flight *synchronous* command at a time and at most one in-flight + *async* command / subscription at a time. Concurrent callers are + serialised by ``_resp_lock`` and ``_cmd_resp_lock`` respectively. + + **Quickstart**:: + + from csm_tcp_router import TcpRouterClient + + with TcpRouterClient() as client: + client.connect("localhost", 30007) + print(client.list_modules()) + + **Protocol flows**: + + - *Synchronous* command (``-@``): :meth:`send_and_wait` – sends a ``CMD`` + packet and blocks until a ``RESP`` (or ``ERROR``) is received. + - *Asynchronous* command (``->``): :meth:`post` – sends a ``CMD`` packet + and blocks until the ``CMD_RESP`` handshake is received; the eventual + ``ASYNC_RESP`` is delivered asynchronously. + - *No-reply async* command (``->|``): :meth:`post_no_reply` – same as + :meth:`post` but no ``ASYNC_RESP`` will ever arrive. + - *Subscribe / unsubscribe*: :meth:`subscribe_status` / + :meth:`unsubscribe_status` – sends a ```` / ```` + command and waits for the ``CMD_RESP`` handshake. + + **Received-packet routing** (on the background receive thread): + + - ``RESP`` (0x04) – unblocks the caller of :meth:`send_and_wait`. + - ``CMD_RESP`` (0x03) – unblocks callers of :meth:`post`, + :meth:`post_no_reply`, :meth:`subscribe_status`, and + :meth:`unsubscribe_status`. + - ``ASYNC_RESP`` (0x05) – added to :attr:`async_response_queue` and + dispatched to any matching :meth:`register_async_callback`. + - ``STATUS`` / ``INTERRUPT`` (0x06 / 0x07) – added to + :attr:`status_queue` and dispatched to any matching + :meth:`subscribe_status` callback. + - ``ERROR`` (0x01) – unblocks any pending synchronous waiter with a + :exc:`~csm_tcp_router.exceptions.ServerError`. + - ``INFO`` (0x00) – silently discarded (welcome / goodbye messages). + """ + + def __init__(self) -> None: + self._transport = Transport( + on_packet=self._on_packet, + on_disconnect=self._on_disconnect, + ) + + # One-item-deep queues for synchronised waits. + # Items are either Packet or Exception instances. + self._resp_queue: queue.Queue[_QueueItem] = queue.Queue() + self._cmd_resp_queue: queue.Queue[_QueueItem] = queue.Queue() + + #: Polling queue for :class:`~csm_tcp_router.models.AsyncResponse` + #: objects received from the server. + self.async_response_queue: queue.Queue[AsyncResponse] = queue.Queue() + + #: Polling queue for :class:`~csm_tcp_router.models.StatusNotification` + #: objects received from the server. + self.status_queue: queue.Queue[StatusNotification] = queue.Queue() + + # Callback registries (protected by _lock) + self._status_callbacks: Dict[_SubKey, Optional[StatusCallback]] = {} + self._async_callbacks: Dict[str, AsyncCallback] = {} + self._lock = threading.Lock() + + # Serialisation locks – at most one in-flight RESP / CMD_RESP waiter + # at a time. This prevents concurrent callers from consuming each + # other's response packets. + 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:`~csm_tcp_router.exceptions.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. + + :param host: Server hostname or IP address. + :param port: Server TCP port. + :param timeout: Maximum time to wait in seconds. + :param retry_interval: Pause between retries in seconds. + :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. + + :param command: CSM command string. + :param timeout: Seconds to wait for the ``resp`` packet. + :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`. + + :param command: CSM command string including the ``->`` suffix. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :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. + + :param command: CSM command string including the ``->|`` suffix. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :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 ping(self, timeout: float = 2.0) -> Tuple[bool, float]: + """Send a ``Ping`` command and measure round-trip latency. + + :param timeout: Seconds to wait for the reply. + :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. + + Equivalent to the ``List`` router management command. + """ + 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:`~csm_tcp_router.models.StatusNotification` objects will be: + + * delivered to *callback* (if provided), and + * added to :attr:`status_queue`. + + :param status_name: Name of the status to subscribe to (e.g. ``"Status"``). + :param module_name: Name of the CSM module (e.g. ``"AI"``). + :param callback: Optional callable invoked on each notification. + Must be fast and non-blocking (runs in the recv thread). + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the subscription. + """ + # 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. + + :param status_name: Name of the subscribed status. + :param module_name: Name of the CSM module. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the request. + """ + 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). + + :param original_command: The command text that will appear in the + ``async-resp`` echo + (e.g. ``"API: Read -> DAQmx"``). + :param callback: Callable receiving an + :class:`~csm_tcp_router.models.AsyncResponse`. + """ + 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 diff --git a/SDK/python-package/src/csm_tcp_router/exceptions.py b/SDK/python-package/src/csm_tcp_router/exceptions.py new file mode 100644 index 0000000..e79ad48 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/exceptions.py @@ -0,0 +1,45 @@ +"""Exception hierarchy for csm-tcp-router-client.""" + +__all__ = [ + "ConnectionError", + "ProtocolError", + "ServerError", + "TcpRouterError", + "TimeoutError", +] + + +class TcpRouterError(Exception): + """Base exception for all CSM-TCP-Router client errors.""" + + +class ConnectionError(TcpRouterError): + """Raised when a connection cannot be established or is lost.""" + + +class TimeoutError(TcpRouterError): + """Raised when a synchronous operation exceeds its timeout.""" + + +class ProtocolError(TcpRouterError): + """Raised when an invalid or unexpected protocol frame is received.""" + + +class ServerError(TcpRouterError): + """Raised when the server returns an error packet. + + Attributes: + message: Human-readable error text from the server. + code: Optional error code extracted from the CSM Error format + ``[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 diff --git a/SDK/python-package/src/csm_tcp_router/models.py b/SDK/python-package/src/csm_tcp_router/models.py new file mode 100644 index 0000000..70ec0e2 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/models.py @@ -0,0 +1,151 @@ +"""Public data models and enumerations for the CSM-TCP-Router protocol.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum + +__all__ = [ + "AsyncResponse", + "CommandResponse", + "Packet", + "PacketType", + "StatusNotification", +] + + +class PacketType(IntEnum): + """Packet type constants as defined in the CSM-TCP-Router protocol v0. + + Wire values + ----------- + ``INFO`` 0x00 – informational messages (welcome / goodbye) + ``ERROR`` 0x01 – error messages from the server + ``CMD`` 0x02 – command sent by the client + ``CMD_RESP`` 0x03 – server handshake for async / no-reply / subscribe + ``RESP`` 0x04 – synchronous response payload + ``ASYNC_RESP`` 0x05 – asynchronous response payload + ``STATUS`` 0x06 – status broadcast from a subscribed CSM module + ``INTERRUPT`` 0x07 – interrupt broadcast from a subscribed CSM module + """ + + INFO = 0x00 + ERROR = 0x01 + CMD = 0x02 + CMD_RESP = 0x03 + RESP = 0x04 + ASYNC_RESP = 0x05 + STATUS = 0x06 + INTERRUPT = 0x07 + + +@dataclass(frozen=True) +class Packet: + """A decoded packet received from the server (internal representation).""" + + type: PacketType + data: bytes + version: int = 1 + flag1: int = 0 + flag2: int = 0 + + +@dataclass(frozen=True) +class CommandResponse: + """The result of a synchronous command (:meth:`~csm_tcp_router.TcpRouterClient.send_and_wait`).""" + + raw: bytes + + @property + def text(self) -> str: + """Decoded UTF-8 text of the response payload.""" + return self.raw.decode("utf-8", errors="replace") + + def __repr__(self) -> str: + return f"CommandResponse({self.text!r})" + + +@dataclass(frozen=True) +class AsyncResponse: + """An asynchronous response payload delivered via an ``async-resp`` packet. + + Attributes: + raw: Raw response bytes (the part *before* the `` <- `` separator). + original_command: The original command text echoed back by the server + (the part *after* the `` <- `` separator). + """ + + raw: bytes + original_command: str = "" + + @property + def text(self) -> str: + """Decoded UTF-8 text of the response payload.""" + return self.raw.decode("utf-8", errors="replace") + + @classmethod + def from_packet(cls, packet: Packet) -> AsyncResponse: + """Parse an ``ASYNC_RESP`` packet. + + Server format: ``" <- "``. + """ + 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: + """A status broadcast delivered via a ``status`` or ``interrupt`` packet. + + Attributes: + raw: Full raw payload bytes. + packet_type: Either :attr:`PacketType.STATUS` or + :attr:`PacketType.INTERRUPT`. + status_name: The name of the broadcasted status (left of ``>>``). + data: The status payload (between ``>>`` and ``<-``). + module_name: The sending CSM module name (right of ``<-``). + """ + + raw: bytes + packet_type: PacketType = PacketType.STATUS + status_name: str = "" + data: str = "" + module_name: str = "" + + @classmethod + def from_packet(cls, packet: Packet) -> StatusNotification: + """Parse a ``STATUS`` or ``INTERRUPT`` packet. + + Server format: ``" >> <- "``. + """ + 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})" + ) diff --git a/SDK/python-package/tests/__init__.py b/SDK/python-package/tests/__init__.py new file mode 100644 index 0000000..9cece0a --- /dev/null +++ b/SDK/python-package/tests/__init__.py @@ -0,0 +1 @@ +# tests package marker diff --git a/SDK/python-package/tests/conftest.py b/SDK/python-package/tests/conftest.py new file mode 100644 index 0000000..8055db0 --- /dev/null +++ b/SDK/python-package/tests/conftest.py @@ -0,0 +1,211 @@ +"""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._protocol import HEADER_SIZE, encode_packet +from csm_tcp_router.models import PacketType + +# --------------------------------------------------------------------------- +# 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-package/tests/test_async_client.py b/SDK/python-package/tests/test_async_client.py new file mode 100644 index 0000000..7047ec2 --- /dev/null +++ b/SDK/python-package/tests/test_async_client.py @@ -0,0 +1,455 @@ +"""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 import AsyncTcpRouterClient +from csm_tcp_router.async_client import _parse_server_error +from csm_tcp_router.exceptions import ( + ConnectionError as RouterConnectionError, +) +from csm_tcp_router.exceptions import ( + ServerError, +) +from csm_tcp_router.exceptions import ( + TimeoutError as RouterTimeoutError, +) +from csm_tcp_router.models import AsyncResponse, Packet, PacketType, StatusNotification + +# --------------------------------------------------------------------------- +# 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-package/tests/test_client.py b/SDK/python-package/tests/test_client.py new file mode 100644 index 0000000..4f824df --- /dev/null +++ b/SDK/python-package/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 TcpRouterClient, _parse_server_error +from csm_tcp_router.exceptions import ( + ConnectionError as RouterConnectionError, +) +from csm_tcp_router.exceptions import ( + ServerError, +) +from csm_tcp_router.exceptions import ( + TimeoutError as RouterTimeoutError, +) +from csm_tcp_router.models import AsyncResponse, Packet, PacketType, StatusNotification + +# --------------------------------------------------------------------------- +# 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-package/tests/test_integration.py b/SDK/python-package/tests/test_integration.py new file mode 100644 index 0000000..563dfd1 --- /dev/null +++ b/SDK/python-package/tests/test_integration.py @@ -0,0 +1,220 @@ +"""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 import TcpRouterClient +from csm_tcp_router.exceptions import ServerError +from csm_tcp_router.exceptions import TimeoutError as RouterTimeoutError +from csm_tcp_router.models import StatusNotification + +# 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.exceptions 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-package/tests/test_protocol.py b/SDK/python-package/tests/test_protocol.py new file mode 100644 index 0000000..bb5c12e --- /dev/null +++ b/SDK/python-package/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._protocol import ( + HEADER_SIZE, + PROTOCOL_VERSION, + decode_header, + encode_packet, + parse_packet, +) +from csm_tcp_router.exceptions import ProtocolError +from csm_tcp_router.models import PacketType + +# --------------------------------------------------------------------------- +# 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 +面向指定客户端上下文取消广播订阅。 From dbb3ec598fe1b6fff81d1405e700f298846bba29 Mon Sep 17 00:00:00 2001 From: nevstop Date: Mon, 27 Apr 2026 18:52:51 +0800 Subject: [PATCH 04/10] update PythonClientAPI/* --- .doc/CSM-TCP-Router.excalidraw | 854 ++++++++++-------- .doc/Protocol.v0.(zh-cn).md | 1 + .doc/{ => obsolete}/Protocol.v1.(zh-cn).md | 0 CSM-TCP-Router.lvproj | 4 + SDK/PythonClientAPI/README.md | 166 ---- SDK/PythonClientAPI/example_usage.py | 103 --- SDK/PythonClientAPI/tcp_router_client.py | 259 ------ SDK/{python-package => python}/CHANGELOG.md | 0 SDK/{python-package => python}/LICENSE | 0 SDK/{python-package => python}/README.md | 0 .../README.zh-cn.md | 0 .../examples/async_usage.py | 0 .../examples/basic_usage.py | 0 .../examples/subscribe_status.py | 0 SDK/{python-package => python}/pyproject.toml | 0 .../src/csm_tcp_router/__init__.py | 0 .../src/csm_tcp_router/_errors.py | 0 .../src/csm_tcp_router/_protocol.py | 0 .../src/csm_tcp_router/_transport.py | 0 .../src/csm_tcp_router/async_client.py | 0 .../src/csm_tcp_router/client.py | 0 .../src/csm_tcp_router/exceptions.py | 0 .../src/csm_tcp_router/models.py | 0 .../tests/__init__.py | 0 .../tests/conftest.py | 0 .../tests/test_async_client.py | 0 .../tests/test_client.py | 0 .../tests/test_integration.py | 0 .../tests/test_protocol.py | 0 29 files changed, 462 insertions(+), 925 deletions(-) rename .doc/{ => obsolete}/Protocol.v1.(zh-cn).md (100%) delete mode 100644 SDK/PythonClientAPI/README.md delete mode 100644 SDK/PythonClientAPI/example_usage.py delete mode 100644 SDK/PythonClientAPI/tcp_router_client.py rename SDK/{python-package => python}/CHANGELOG.md (100%) rename SDK/{python-package => python}/LICENSE (100%) rename SDK/{python-package => python}/README.md (100%) rename SDK/{python-package => python}/README.zh-cn.md (100%) rename SDK/{python-package => python}/examples/async_usage.py (100%) rename SDK/{python-package => python}/examples/basic_usage.py (100%) rename SDK/{python-package => python}/examples/subscribe_status.py (100%) rename SDK/{python-package => python}/pyproject.toml (100%) rename SDK/{python-package => python}/src/csm_tcp_router/__init__.py (100%) rename SDK/{python-package => python}/src/csm_tcp_router/_errors.py (100%) rename SDK/{python-package => python}/src/csm_tcp_router/_protocol.py (100%) rename SDK/{python-package => python}/src/csm_tcp_router/_transport.py (100%) rename SDK/{python-package => python}/src/csm_tcp_router/async_client.py (100%) rename SDK/{python-package => python}/src/csm_tcp_router/client.py (100%) rename SDK/{python-package => python}/src/csm_tcp_router/exceptions.py (100%) rename SDK/{python-package => python}/src/csm_tcp_router/models.py (100%) rename SDK/{python-package => python}/tests/__init__.py (100%) rename SDK/{python-package => python}/tests/conftest.py (100%) rename SDK/{python-package => python}/tests/test_async_client.py (100%) rename SDK/{python-package => python}/tests/test_client.py (100%) rename SDK/{python-package => python}/tests/test_integration.py (100%) rename SDK/{python-package => python}/tests/test_protocol.py (100%) diff --git a/.doc/CSM-TCP-Router.excalidraw b/.doc/CSM-TCP-Router.excalidraw index fb64d1a..b9b5455 100644 --- a/.doc/CSM-TCP-Router.excalidraw +++ b/.doc/CSM-TCP-Router.excalidraw @@ -1,15 +1,15 @@ { "type": "excalidraw", "version": 2, - "source": "https://excalidraw.com", + "source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor", "elements": [ { "id": "06097f4ce4a944f5b840", "type": "rectangle", - "x": -40.0, - "y": 1060.0, - "width": 710.0, - "height": 260.0, + "x": -377.1428571428571, + "y": 1010.8571951729912, + "width": 710, + "height": 260, "angle": 0, "strokeColor": "#666666", "backgroundColor": "#f5f5f5", @@ -24,8 +24,8 @@ "type": 3 }, "seed": 1516676960, - "version": 1, - "versionNonce": 894759244, + "version": 69, + "versionNonce": 1599584990, "isDeleted": false, "boundElements": [ { @@ -33,17 +33,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287137300, "link": null, - "locked": false + "locked": false, + "index": "a0" }, { "id": "40d09f28b6f5449b8416", "type": "text", - "x": 196.68, - "y": 1175.0, + "x": -140.4628571428571, + "y": 1125.8571951729912, "width": 236.64, - "height": 30.0, + "height": 30, "angle": 0, "strokeColor": "#67AB9F", "backgroundColor": "transparent", @@ -56,11 +57,11 @@ "frameId": null, "roundness": null, "seed": 1359914060, - "version": 1, - "versionNonce": 267946355, + "version": 69, + "versionNonce": 1083748126, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287137300, "link": null, "locked": false, "text": "Server Application", @@ -71,15 +72,16 @@ "containerId": "06097f4ce4a944f5b840", "originalText": "Server Application", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "a1" }, { "id": "f2931a99db14441d92c2", "type": "rectangle", - "x": 250.0, - "y": 70.0, - "width": 570.0, - "height": 630.0, + "x": 201.42848423549094, + "y": -51.142839704241055, + "width": 570, + "height": 630, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -94,8 +96,8 @@ "type": 3 }, "seed": 334935149, - "version": 1, - "versionNonce": 912941967, + "version": 35, + "versionNonce": 479119682, "isDeleted": false, "boundElements": [ { @@ -103,17 +105,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287133715, "link": null, - "locked": false + "locked": false, + "index": "a2" }, { "id": "d39d2f1397d84cf29c94", "type": "text", - "x": 367.96000000000004, - "y": 365.0, + "x": 319.388484235491, + "y": 243.85716029575894, "width": 334.08, - "height": 40.0, + "height": 40, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -126,11 +129,11 @@ "frameId": null, "roundness": null, "seed": 1166131094, - "version": 1, - "versionNonce": 1488794127, + "version": 35, + "versionNonce": 1613254914, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287133715, "link": null, "locked": false, "text": "Server Application", @@ -141,15 +144,16 @@ "containerId": "f2931a99db14441d92c2", "originalText": "Server Application", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "a3" }, { "id": "3a12c9cd83ac49d4af82", "type": "rectangle", - "x": 320.0, - "y": 120.0, - "width": 450.0, - "height": 340.0, + "x": 271.42848423549094, + "y": -1.1428397042410552, + "width": 450, + "height": 340, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -164,24 +168,29 @@ "type": 3 }, "seed": 405658968, - "version": 1, - "versionNonce": 1007193791, + "version": 53, + "versionNonce": 1320870082, "isDeleted": false, "boundElements": [ { "id": "96fc065142d741bfab28", "type": "text" + }, + { + "id": "4343ca43bb1b46aaab89", + "type": "arrow" } ], - "updated": 0, + "updated": 1777287133715, "link": null, - "locked": false + "locked": false, + "index": "a4" }, { "id": "96fc065142d741bfab28", "type": "text", - "x": 411.89, - "y": 273.125, + "x": 363.3184842354909, + "y": 3.857160295758945, "width": 266.21999999999997, "height": 33.75, "angle": 0, @@ -196,30 +205,31 @@ "frameId": null, "roundness": null, "seed": 1687535625, - "version": 1, - "versionNonce": 1573432143, + "version": 54, + "versionNonce": 870676610, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287133715, "link": null, "locked": false, "text": "CSM Module System", "fontSize": 27, "fontFamily": 1, "textAlign": "center", - "verticalAlign": "middle", + "verticalAlign": "top", "containerId": "3a12c9cd83ac49d4af82", "originalText": "CSM Module System", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "a5" }, { "id": "58eb2e1dfd49455083d7", "type": "rectangle", - "x": 370.0, - "y": 190.0, - "width": 170.0, - "height": 110.0, + "x": 288.2284720284597, + "y": 68.85716029575894, + "width": 203.20001220703125, + "height": 110, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -234,8 +244,8 @@ "type": 3 }, "seed": 828310685, - "version": 1, - "versionNonce": 1098432173, + "version": 70, + "versionNonce": 287227906, "isDeleted": false, "boundElements": [ { @@ -243,17 +253,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287133715, "link": null, - "locked": false + "locked": false, + "index": "a6" }, { "id": "b4cb0e46d8754a6ba28b", "type": "text", - "x": 374.0, - "y": 232.5, - "width": 162.0, - "height": 25.0, + "x": 304.7285406930105, + "y": 111.35716029575894, + "width": 170.1998748779297, + "height": 25, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -266,11 +277,11 @@ "frameId": null, "roundness": null, "seed": 540651128, - "version": 1, - "versionNonce": 643699448, + "version": 72, + "versionNonce": 353071042, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287133715, "link": null, "locked": false, "text": "AI (CSM Module1)", @@ -281,15 +292,16 @@ "containerId": "58eb2e1dfd49455083d7", "originalText": "AI (CSM Module1)", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "a7" }, { "id": "8a5c90e4c9214aff98d8", "type": "rectangle", - "x": 370.0, - "y": 310.0, - "width": 170.0, - "height": 110.0, + "x": 288.2284720284597, + "y": 188.85716029575894, + "width": 203.20001220703125, + "height": 110, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -304,26 +316,31 @@ "type": 3 }, "seed": 2061525606, - "version": 1, - "versionNonce": 1612758244, + "version": 71, + "versionNonce": 525084546, "isDeleted": false, "boundElements": [ { "id": "15dba5756d714bc5be93", "type": "text" + }, + { + "id": "c967d534ee594bd8b6f7", + "type": "arrow" } ], - "updated": 0, + "updated": 1777287133715, "link": null, - "locked": false + "locked": false, + "index": "a8" }, { "id": "15dba5756d714bc5be93", "type": "text", - "x": 374.0, - "y": 352.5, - "width": 162.0, - "height": 25.0, + "x": 337.27851322719016, + "y": 218.85716029575894, + "width": 105.09992980957031, + "height": 50, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -336,14 +353,14 @@ "frameId": null, "roundness": null, "seed": 1094784021, - "version": 1, - "versionNonce": 1455312304, + "version": 71, + "versionNonce": 1478485826, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287133715, "link": null, "locked": false, - "text": "DIO1 (CSM Module2)", + "text": "DIO1 (CSM\nModule2)", "fontSize": 20, "fontFamily": 1, "textAlign": "center", @@ -351,15 +368,16 @@ "containerId": "8a5c90e4c9214aff98d8", "originalText": "DIO1 (CSM Module2)", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "a9" }, { "id": "378abd886fa942b5ae6f", "type": "rectangle", - "x": 550.0, - "y": 190.0, - "width": 170.0, - "height": 110.0, + "x": 501.42848423549094, + "y": 68.85716029575894, + "width": 200, + "height": 110, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -374,8 +392,8 @@ "type": 3 }, "seed": 1968915559, - "version": 1, - "versionNonce": 137789224, + "version": 52, + "versionNonce": 320634562, "isDeleted": false, "boundElements": [ { @@ -383,17 +401,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287133715, "link": null, - "locked": false + "locked": false, + "index": "aA" }, { "id": "867f1a5210ac42c7a82d", "type": "text", - "x": 554.0, - "y": 232.5, - "width": 162.0, - "height": 25.0, + "x": 548.8785193307058, + "y": 98.85716029575894, + "width": 105.09992980957031, + "height": 50, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -406,14 +425,14 @@ "frameId": null, "roundness": null, "seed": 1862328426, - "version": 1, - "versionNonce": 1835601418, + "version": 53, + "versionNonce": 1986221698, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287133715, "link": null, "locked": false, - "text": "DIO1 (CSM Module3)", + "text": "DIO1 (CSM\nModule3)", "fontSize": 20, "fontFamily": 1, "textAlign": "center", @@ -421,15 +440,16 @@ "containerId": "378abd886fa942b5ae6f", "originalText": "DIO1 (CSM Module3)", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "aB" }, { "id": "adc34df6058e4435acf1", "type": "rectangle", - "x": 550.0, - "y": 310.0, - "width": 170.0, - "height": 110.0, + "x": 501.42848423549094, + "y": 188.85716029575894, + "width": 200, + "height": 110, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -444,8 +464,8 @@ "type": 3 }, "seed": 76596677, - "version": 1, - "versionNonce": 1494446171, + "version": 52, + "versionNonce": 1115240002, "isDeleted": false, "boundElements": [ { @@ -453,17 +473,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287133715, "link": null, - "locked": false + "locked": false, + "index": "aC" }, { "id": "109e949f39d34b6cb355", "type": "text", - "x": 554.0, - "y": 352.5, - "width": 162.0, - "height": 25.0, + "x": 531.4185431344167, + "y": 218.85716029575894, + "width": 140.01988220214844, + "height": 50, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -476,14 +497,14 @@ "frameId": null, "roundness": null, "seed": 1205658623, - "version": 1, - "versionNonce": 1551557532, + "version": 53, + "versionNonce": 406658562, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287133715, "link": null, "locked": false, - "text": "Measure (CSM Module4)", + "text": "Measure (CSM\nModule4)", "fontSize": 20, "fontFamily": 1, "textAlign": "center", @@ -491,15 +512,16 @@ "containerId": "adc34df6058e4435acf1", "originalText": "Measure (CSM Module4)", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "aD" }, { "id": "72a83ed504ed4d0da987", "type": "rectangle", - "x": 320.0, - "y": 560.0, - "width": 450.0, - "height": 100.0, + "x": 262.62843540736594, + "y": 438.85716029575894, + "width": 458.800048828125, + "height": 100, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -514,26 +536,31 @@ "type": 3 }, "seed": 513845840, - "version": 1, - "versionNonce": 1692515392, + "version": 87, + "versionNonce": 615386562, "isDeleted": false, "boundElements": [ { "id": "3b93d90e9ba740ec8d2c", "type": "text" + }, + { + "id": "4343ca43bb1b46aaab89", + "type": "arrow" } ], - "updated": 0, + "updated": 1777287133715, "link": null, - "locked": false + "locked": false, + "index": "aE" }, { "id": "3b93d90e9ba740ec8d2c", "type": "text", - "x": 324.0, - "y": 590.0, - "width": 442.0, - "height": 40.0, + "x": 272.20451572963157, + "y": 448.85716029575894, + "width": 439.64788818359375, + "height": 80, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -546,14 +573,14 @@ "frameId": null, "roundness": null, "seed": 133198406, - "version": 1, - "versionNonce": 2087443845, + "version": 89, + "versionNonce": 485164418, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287133715, "link": null, "locked": false, - "text": "CSM TCP Router (based on JKI TCP Server)", + "text": "CSM TCP Router (based on\nJKI TCP Server)", "fontSize": 32, "fontFamily": 1, "textAlign": "center", @@ -561,15 +588,16 @@ "containerId": "72a83ed504ed4d0da987", "originalText": "CSM TCP Router (based on JKI TCP Server)", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "aF" }, { "id": "66d9bb7d39ed49bab977", "type": "rectangle", - "x": -330.0, - "y": 80.0, - "width": 470.0, - "height": 620.0, + "x": -556.1715218680247, + "y": -41.142839704241055, + "width": 647.6000061035156, + "height": 620, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -584,26 +612,31 @@ "type": 3 }, "seed": 1857846310, - "version": 1, - "versionNonce": 2135205116, + "version": 78, + "versionNonce": 904203522, "isDeleted": false, "boundElements": [ { "id": "d61e4a50c7424aafb49c", "type": "text" + }, + { + "id": "c967d534ee594bd8b6f7", + "type": "arrow" } ], - "updated": 0, + "updated": 1777287133715, "link": null, - "locked": false + "locked": false, + "index": "aG" }, { "id": "d61e4a50c7424aafb49c", "type": "text", - "x": -262.03999999999996, - "y": 370.0, - "width": 334.08, - "height": 40.0, + "x": -365.21946498325906, + "y": 248.85716029575894, + "width": 265.6958923339844, + "height": 40, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -616,11 +649,11 @@ "frameId": null, "roundness": null, "seed": 531398026, - "version": 1, - "versionNonce": 1296356900, + "version": 78, + "versionNonce": 1166023874, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287133715, "link": null, "locked": false, "text": "Client Application", @@ -631,15 +664,16 @@ "containerId": "66d9bb7d39ed49bab977", "originalText": "Client Application", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "aH" }, { "id": "d4b5bc67b5e84b86aec2", "type": "rectangle", - "x": -300.0, - "y": 300.0, - "width": 340.0, - "height": 160.0, + "x": -510.97150966099343, + "y": 161.12381837229248, + "width": 564.7999877929688, + "height": 190.39999050564236, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -652,8 +686,8 @@ "frameId": null, "roundness": null, "seed": 451019402, - "version": 1, - "versionNonce": 2020205762, + "version": 149, + "versionNonce": 1259164738, "isDeleted": false, "boundElements": [ { @@ -661,17 +695,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287133715, "link": null, - "locked": false + "locked": false, + "index": "aI" }, { "id": "e14f5e9cd4674be1bda4", "type": "text", - "x": -296.0, - "y": 317.5, - "width": 332.0, - "height": 125.0, + "x": -505.97150966099343, + "y": 181.32381362511364, + "width": 479.3798828125, + "height": 150, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -684,30 +719,31 @@ "frameId": null, "roundness": null, "seed": 1029267775, - "version": 1, - "versionNonce": 738718317, + "version": 166, + "versionNonce": 1956477954, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287133715, "link": null, "locked": false, - "text": "Server Build-in Command\n\nCSM TCP Router 定义的指令,用于管理和显示帮助- List: 列出所有的 CSM 模块\n- List API: 列出CSM 模块的参数\n- Help: 显示CSM模块的帮助(VI Description)", + "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": "center", + "textAlign": "left", "verticalAlign": "middle", "containerId": "d4b5bc67b5e84b86aec2", - "originalText": "Server Build-in Command\n\nCSM TCP Router 定义的指令,用于管理和显示帮助- List: 列出所有的 CSM 模块\n- List API: 列出CSM 模块的参数\n- Help: 显示CSM模块的帮助(VI Description)", + "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 + "autoResize": false, + "index": "aJ" }, { "id": "c44e3d5dc3a5490cb039", "type": "rectangle", - "x": -300.0, - "y": 490.0, - "width": 340.0, - "height": 160.0, + "x": -511.7714974539622, + "y": 369.39047644882606, + "width": 564.7999877929688, + "height": 145.06665943287038, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -720,8 +756,8 @@ "frameId": null, "roundness": null, "seed": 286961250, - "version": 1, - "versionNonce": 1108591278, + "version": 185, + "versionNonce": 290630594, "isDeleted": false, "boundElements": [ { @@ -729,17 +765,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287133715, "link": null, - "locked": false + "locked": false, + "index": "aK" }, { "id": "ec5f57e035cf45908018", "type": "text", - "x": -296.0, - "y": 532.5, - "width": 332.0, - "height": 75.0, + "x": -506.7714974539622, + "y": 379.4238061652612, + "width": 536.919921875, + "height": 125, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -752,30 +789,31 @@ "frameId": null, "roundness": null, "seed": 1260928053, - "version": 1, - "versionNonce": 1047655250, + "version": 207, + "versionNonce": 122413954, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287133715, "link": null, "locked": false, - "text": "Server  CSM Command\n由 CSM 模块定义的指令\nCSM 的所有消息,都被转发到 CSM Modules 中执行,因此支持的消息种类,由Server 中的CSM Module System 中的CSM模块决定", + "text": "Server  CSM Command\n由 CSM 模块定义的指令\nCSM 的所有消息,都被转发到 CSM Modules 中执行,因此\n支持的消息种类,由Server 中的CSM Module System 中的\nCSM模块决定", "fontSize": 20, "fontFamily": 1, - "textAlign": "center", + "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 + "autoResize": false, + "index": "aL" }, { "id": "68d09aa25b504907b22d", "type": "rectangle", - "x": -300.0, - "y": 110.0, - "width": 340.0, - "height": 160.0, + "x": -510.97150966099343, + "y": -11.142839704241055, + "width": 564.7999877929688, + "height": 167.7333249692564, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -788,8 +826,8 @@ "frameId": null, "roundness": null, "seed": 1280667824, - "version": 1, - "versionNonce": 645069776, + "version": 149, + "versionNonce": 549987138, "isDeleted": false, "boundElements": [ { @@ -797,17 +835,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287133715, "link": null, - "locked": false + "locked": false, + "index": "aM" }, { "id": "0916692baae84d6a8cb2", "type": "text", - "x": -296.0, - "y": 127.5, - "width": 332.0, - "height": 125.0, + "x": -505.97150966099343, + "y": 10.223822780387138, + "width": 493.7198486328125, + "height": 125, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -820,30 +859,31 @@ "frameId": null, "roundness": null, "seed": 1861740026, - "version": 1, - "versionNonce": 1550368398, + "version": 162, + "versionNonce": 136735490, "isDeleted": false, - "boundElements": null, - "updated": 0, + "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": "center", + "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 + "autoResize": false, + "index": "aN" }, { "id": "c3d7e032eee9479d92fe", "type": "rectangle", - "x": -10.0, - "y": 1090.0, - "width": 460.0, - "height": 60.0, + "x": -347.1428571428571, + "y": 1040.8571951729912, + "width": 647.199951171875, + "height": 60, "angle": 0, "strokeColor": "#82b366", "backgroundColor": "#d5e8d4", @@ -858,8 +898,8 @@ "type": 3 }, "seed": 616346550, - "version": 1, - "versionNonce": 1272916373, + "version": 89, + "versionNonce": 1848712030, "isDeleted": false, "boundElements": [ { @@ -867,17 +907,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287137300, "link": null, - "locked": false + "locked": false, + "index": "aO" }, { "id": "ba1ba4b5800841ef99ba", "type": "text", - "x": 80.80000000000001, - "y": 1105.0, - "width": 278.4, - "height": 30.0, + "x": -154.28281075613836, + "y": 1055.8571951729912, + "width": 261.4798583984375, + "height": 30, "angle": 0, "strokeColor": "#67AB9F", "backgroundColor": "transparent", @@ -890,11 +931,11 @@ "frameId": null, "roundness": null, "seed": 1360334683, - "version": 1, - "versionNonce": 721767282, + "version": 90, + "versionNonce": 1584529310, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287137300, "link": null, "locked": false, "text": "TCP Layer( Reusable)", @@ -905,15 +946,16 @@ "containerId": "c3d7e032eee9479d92fe", "originalText": "TCP Layer( Reusable)", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "aP" }, { "id": "f1fcce1f99dc4c1f8847", "type": "rectangle", - "x": -10.0, - "y": 1160.0, - "width": 460.0, - "height": 140.0, + "x": -347.1428571428571, + "y": 1110.8571951729912, + "width": 647.199951171875, + "height": 140, "angle": 0, "strokeColor": "#b85450", "backgroundColor": "#f8cecc", @@ -926,8 +968,8 @@ "frameId": null, "roundness": null, "seed": 527125112, - "version": 1, - "versionNonce": 1980260540, + "version": 85, + "versionNonce": 361520094, "isDeleted": false, "boundElements": [ { @@ -935,17 +977,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287137300, "link": null, - "locked": false + "locked": false, + "index": "aQ" }, { "id": "5bab6bdeb62b40228319", "type": "text", - "x": -6.0, - "y": 1217.5, - "width": 452.0, - "height": 25.0, + "x": -309.78268868582586, + "y": 1168.3571951729912, + "width": 572.4796142578125, + "height": 25, "angle": 0, "strokeColor": "#67AB9F", "backgroundColor": "transparent", @@ -958,11 +1001,11 @@ "frameId": null, "roundness": null, "seed": 894462135, - "version": 1, - "versionNonce": 378597871, + "version": 87, + "versionNonce": 1771122718, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287137300, "link": null, "locked": false, "text": "Code Based CSM Framework(Based on the Requirements)", @@ -973,15 +1016,16 @@ "containerId": "f1fcce1f99dc4c1f8847", "originalText": "Code Based CSM Framework(Based on the Requirements)", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "aR" }, { "id": "5c296355ff174e0195e2", "type": "rectangle", - "x": 50.0, - "y": 790.0, - "width": 100.0, - "height": 100.0, + "x": -272.85714285714266, + "y": 636.857125418527, + "width": 100, + "height": 100, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#D2D3D3", @@ -994,8 +1038,8 @@ "frameId": null, "roundness": null, "seed": 425614159, - "version": 1, - "versionNonce": 501325967, + "version": 71, + "versionNonce": 1181842526, "isDeleted": false, "boundElements": [ { @@ -1003,17 +1047,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287137300, "link": null, - "locked": false + "locked": false, + "index": "aS" }, { "id": "b543c6d67fdf4c61a3f0", "type": "text", - "x": 54.0, - "y": 827.5, - "width": 92.0, - "height": 25.0, + "x": -268.85714285714266, + "y": 661.857125418527, + "width": 92, + "height": 50, "angle": 0, "strokeColor": "#67AB9F", "backgroundColor": "transparent", @@ -1026,14 +1071,14 @@ "frameId": null, "roundness": null, "seed": 91453709, - "version": 1, - "versionNonce": 884719392, + "version": 73, + "versionNonce": 1912488094, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287137300, "link": null, "locked": false, - "text": "TCP Client", + "text": "TCP\nClient", "fontSize": 20, "fontFamily": 1, "textAlign": "center", @@ -1041,15 +1086,16 @@ "containerId": "5c296355ff174e0195e2", "originalText": "TCP Client", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "aT" }, { "id": "b56d185ccff9414282e7", "type": "rectangle", - "x": 170.0, - "y": 790.0, - "width": 100.0, - "height": 100.0, + "x": -152.85714285714266, + "y": 636.857125418527, + "width": 100, + "height": 100, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#D2D3D3", @@ -1062,8 +1108,8 @@ "frameId": null, "roundness": null, "seed": 1346551058, - "version": 1, - "versionNonce": 1266930958, + "version": 71, + "versionNonce": 1340636382, "isDeleted": false, "boundElements": [ { @@ -1071,17 +1117,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287137300, "link": null, - "locked": false + "locked": false, + "index": "aU" }, { "id": "e1c67e3c14464e33954a", "type": "text", - "x": 174.0, - "y": 827.5, - "width": 92.0, - "height": 25.0, + "x": -148.85714285714266, + "y": 661.857125418527, + "width": 92, + "height": 50, "angle": 0, "strokeColor": "#67AB9F", "backgroundColor": "transparent", @@ -1094,14 +1141,14 @@ "frameId": null, "roundness": null, "seed": 757773874, - "version": 1, - "versionNonce": 492781324, + "version": 73, + "versionNonce": 633418014, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287137300, "link": null, "locked": false, - "text": "TCP Client", + "text": "TCP\nClient", "fontSize": 20, "fontFamily": 1, "textAlign": "center", @@ -1109,15 +1156,16 @@ "containerId": "b56d185ccff9414282e7", "originalText": "TCP Client", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "aV" }, { "id": "c8513775c6fe4da58f55", "type": "rectangle", - "x": 290.0, - "y": 790.0, - "width": 100.0, - "height": 100.0, + "x": -32.85714285714266, + "y": 636.857125418527, + "width": 100, + "height": 100, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#D2D3D3", @@ -1130,8 +1178,8 @@ "frameId": null, "roundness": null, "seed": 757589191, - "version": 1, - "versionNonce": 1529136688, + "version": 71, + "versionNonce": 1593400670, "isDeleted": false, "boundElements": [ { @@ -1139,17 +1187,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287137300, "link": null, - "locked": false + "locked": false, + "index": "aW" }, { "id": "709beda0e58b4b5ab6cd", "type": "text", - "x": 294.0, - "y": 827.5, - "width": 92.0, - "height": 25.0, + "x": -28.857142857142662, + "y": 661.857125418527, + "width": 92, + "height": 50, "angle": 0, "strokeColor": "#67AB9F", "backgroundColor": "transparent", @@ -1162,14 +1211,14 @@ "frameId": null, "roundness": null, "seed": 1211271590, - "version": 1, - "versionNonce": 534009896, + "version": 73, + "versionNonce": 1356988830, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287137300, "link": null, "locked": false, - "text": "TCP Client", + "text": "TCP\nClient", "fontSize": 20, "fontFamily": 1, "textAlign": "center", @@ -1177,15 +1226,16 @@ "containerId": "c8513775c6fe4da58f55", "originalText": "TCP Client", "lineHeight": 1.25, - "autoResize": false + "autoResize": false, + "index": "aX" }, { "id": "09a22cc2431a4ca7b1e0", "type": "rectangle", - "x": 370.0, - "y": 910.0, - "width": 430.0, - "height": 120.0, + "x": 33.42878069196445, + "y": 839.1429792131696, + "width": 651.1429268973216, + "height": 120, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "#ffffff", @@ -1198,8 +1248,8 @@ "frameId": null, "roundness": null, "seed": 1821414404, - "version": 1, - "versionNonce": 1294207142, + "version": 187, + "versionNonce": 2125209054, "isDeleted": false, "boundElements": [ { @@ -1207,17 +1257,18 @@ "type": "text" } ], - "updated": 0, + "updated": 1777287137300, "link": null, - "locked": false + "locked": false, + "index": "aY" }, { "id": "627048f5d60843d0a878", "type": "text", - "x": 374.0, - "y": 920.0, - "width": 422.0, - "height": 100.0, + "x": 88.73049926757835, + "y": 849.1429792131696, + "width": 540.5394897460938, + "height": 100, "angle": 0, "strokeColor": "#67AB9F", "backgroundColor": "transparent", @@ -1230,11 +1281,11 @@ "frameId": null, "roundness": null, "seed": 215362962, - "version": 1, - "versionNonce": 1839110588, + "version": 147, + "versionNonce": 1567142430, "isDeleted": false, - "boundElements": null, - "updated": 0, + "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 ...", @@ -1245,15 +1296,16 @@ "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 + "autoResize": false, + "index": "aZ" }, { "id": "4343ca43bb1b46aaab89", "type": "arrow", - "x": 545.0, - "y": 610.0, - "width": 0.0, - "height": -320.0, + "x": 494.02845982142844, + "y": 427.2571236746652, + "width": 0.00006103515625, + "height": 85.5999755859375, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -1266,11 +1318,11 @@ "frameId": null, "roundness": null, "seed": 1789827435, - "version": 1, - "versionNonce": 480108401, + "version": 197, + "versionNonce": 63192862, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287133813, "link": null, "locked": false, "points": [ @@ -1279,26 +1331,32 @@ 0 ], [ - 0.0, - -320.0 + 0.00006103515625, + -85.5999755859375 ] ], - "lastCommittedPoint": [ - 0.0, - -320.0 - ], - "startBinding": null, - "endBinding": null, + "lastCommittedPoint": null, + "startBinding": { + "elementId": "72a83ed504ed4d0da987", + "focus": 0.008718202065400868, + "gap": 11.60003662109375 + }, + "endBinding": { + "elementId": "3a12c9cd83ac49d4af82", + "focus": 0.010665950554903025, + "gap": 2.79998779296875 + }, "startArrowhead": null, - "endArrowhead": "arrow" + "endArrowhead": "arrow", + "index": "aa" }, { "id": "c967d534ee594bd8b6f7", "type": "arrow", - "x": 140.0, - "y": 599.0, - "width": 395.0, - "height": -214.0, + "x": 91.42848423549094, + "y": 477.85716029575894, + "width": 176.19998168945312, + "height": 218.79998779296875, "angle": 0, "strokeColor": "#1e1e1e", "backgroundColor": "transparent", @@ -1311,11 +1369,11 @@ "frameId": null, "roundness": null, "seed": 1930751864, - "version": 1, - "versionNonce": 709055981, + "version": 148, + "versionNonce": 1475973022, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287133814, "link": null, "locked": false, "points": [ @@ -1324,26 +1382,32 @@ 0 ], [ - 395.0, - -214.0 + 176.19998168945312, + -218.79998779296875 ] ], - "lastCommittedPoint": [ - 395.0, - -214.0 - ], - "startBinding": null, - "endBinding": null, + "lastCommittedPoint": null, + "startBinding": { + "elementId": "66d9bb7d39ed49bab977", + "focus": 0.8581630760314127, + "gap": 1 + }, + "endBinding": { + "elementId": "8a5c90e4c9214aff98d8", + "focus": 0.7537063280415125, + "gap": 20.600006103515625 + }, "startArrowhead": null, - "endArrowhead": "arrow" + "endArrowhead": "arrow", + "index": "ab" }, { "id": "c9b3b417bdbf45d89ce0", "type": "arrow", - "x": 100.0, - "y": 840.0, - "width": 120.0, - "height": 280.0, + "x": -222.85714285714266, + "y": 750.8571951729912, + "width": 120, + "height": 280, "angle": 0, "strokeColor": "#97D077", "backgroundColor": "transparent", @@ -1356,11 +1420,11 @@ "frameId": null, "roundness": null, "seed": 1801585414, - "version": 1, - "versionNonce": 373435437, + "version": 120, + "versionNonce": 403226206, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287137300, "link": null, "locked": false, "points": [ @@ -1369,26 +1433,24 @@ 0 ], [ - 120.0, - 280.0 + 120, + 280 ] ], - "lastCommittedPoint": [ - 120.0, - 280.0 - ], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, "startArrowhead": null, - "endArrowhead": "arrow" + "endArrowhead": "arrow", + "index": "ac" }, { "id": "5f55e1df753d4fb2a6b9", "type": "arrow", - "x": 220.0, - "y": 840.0, - "width": 0.0, - "height": 280.0, + "x": -102.85714285714266, + "y": 750.8571951729912, + "width": 0, + "height": 280, "angle": 0, "strokeColor": "#97D077", "backgroundColor": "transparent", @@ -1401,11 +1463,11 @@ "frameId": null, "roundness": null, "seed": 1574452212, - "version": 1, - "versionNonce": 751253604, + "version": 51, + "versionNonce": 1398620830, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287137300, "link": null, "locked": false, "points": [ @@ -1414,26 +1476,24 @@ 0 ], [ - 0.0, - 280.0 + 0, + 280 ] ], - "lastCommittedPoint": [ - 0.0, - 280.0 - ], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, "startArrowhead": null, - "endArrowhead": "arrow" + "endArrowhead": "arrow", + "index": "ad" }, { "id": "7a7d7908178144bb97dd", "type": "arrow", - "x": 340.0, - "y": 840.0, - "width": -120.0, - "height": 280.0, + "x": 17.142857142857338, + "y": 750.8571951729912, + "width": 120, + "height": 280, "angle": 0, "strokeColor": "#97D077", "backgroundColor": "transparent", @@ -1446,11 +1506,11 @@ "frameId": null, "roundness": null, "seed": 1366313377, - "version": 1, - "versionNonce": 633510248, + "version": 51, + "versionNonce": 1914126046, "isDeleted": false, - "boundElements": null, - "updated": 0, + "boundElements": [], + "updated": 1777287137300, "link": null, "locked": false, "points": [ @@ -1459,22 +1519,22 @@ 0 ], [ - -120.0, - 280.0 + -120, + 280 ] ], - "lastCommittedPoint": [ - -120.0, - 280.0 - ], + "lastCommittedPoint": null, "startBinding": null, "endBinding": null, "startArrowhead": null, - "endArrowhead": "arrow" + "endArrowhead": "arrow", + "index": "ae" } ], "appState": { - "gridSize": null, + "gridSize": 20, + "gridStep": 5, + "gridModeEnabled": false, "viewBackgroundColor": "#ffffff" }, "files": {} 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/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/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/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/python-package/CHANGELOG.md b/SDK/python/CHANGELOG.md similarity index 100% rename from SDK/python-package/CHANGELOG.md rename to SDK/python/CHANGELOG.md diff --git a/SDK/python-package/LICENSE b/SDK/python/LICENSE similarity index 100% rename from SDK/python-package/LICENSE rename to SDK/python/LICENSE diff --git a/SDK/python-package/README.md b/SDK/python/README.md similarity index 100% rename from SDK/python-package/README.md rename to SDK/python/README.md diff --git a/SDK/python-package/README.zh-cn.md b/SDK/python/README.zh-cn.md similarity index 100% rename from SDK/python-package/README.zh-cn.md rename to SDK/python/README.zh-cn.md diff --git a/SDK/python-package/examples/async_usage.py b/SDK/python/examples/async_usage.py similarity index 100% rename from SDK/python-package/examples/async_usage.py rename to SDK/python/examples/async_usage.py diff --git a/SDK/python-package/examples/basic_usage.py b/SDK/python/examples/basic_usage.py similarity index 100% rename from SDK/python-package/examples/basic_usage.py rename to SDK/python/examples/basic_usage.py diff --git a/SDK/python-package/examples/subscribe_status.py b/SDK/python/examples/subscribe_status.py similarity index 100% rename from SDK/python-package/examples/subscribe_status.py rename to SDK/python/examples/subscribe_status.py diff --git a/SDK/python-package/pyproject.toml b/SDK/python/pyproject.toml similarity index 100% rename from SDK/python-package/pyproject.toml rename to SDK/python/pyproject.toml diff --git a/SDK/python-package/src/csm_tcp_router/__init__.py b/SDK/python/src/csm_tcp_router/__init__.py similarity index 100% rename from SDK/python-package/src/csm_tcp_router/__init__.py rename to SDK/python/src/csm_tcp_router/__init__.py diff --git a/SDK/python-package/src/csm_tcp_router/_errors.py b/SDK/python/src/csm_tcp_router/_errors.py similarity index 100% rename from SDK/python-package/src/csm_tcp_router/_errors.py rename to SDK/python/src/csm_tcp_router/_errors.py diff --git a/SDK/python-package/src/csm_tcp_router/_protocol.py b/SDK/python/src/csm_tcp_router/_protocol.py similarity index 100% rename from SDK/python-package/src/csm_tcp_router/_protocol.py rename to SDK/python/src/csm_tcp_router/_protocol.py diff --git a/SDK/python-package/src/csm_tcp_router/_transport.py b/SDK/python/src/csm_tcp_router/_transport.py similarity index 100% rename from SDK/python-package/src/csm_tcp_router/_transport.py rename to SDK/python/src/csm_tcp_router/_transport.py diff --git a/SDK/python-package/src/csm_tcp_router/async_client.py b/SDK/python/src/csm_tcp_router/async_client.py similarity index 100% rename from SDK/python-package/src/csm_tcp_router/async_client.py rename to SDK/python/src/csm_tcp_router/async_client.py diff --git a/SDK/python-package/src/csm_tcp_router/client.py b/SDK/python/src/csm_tcp_router/client.py similarity index 100% rename from SDK/python-package/src/csm_tcp_router/client.py rename to SDK/python/src/csm_tcp_router/client.py diff --git a/SDK/python-package/src/csm_tcp_router/exceptions.py b/SDK/python/src/csm_tcp_router/exceptions.py similarity index 100% rename from SDK/python-package/src/csm_tcp_router/exceptions.py rename to SDK/python/src/csm_tcp_router/exceptions.py diff --git a/SDK/python-package/src/csm_tcp_router/models.py b/SDK/python/src/csm_tcp_router/models.py similarity index 100% rename from SDK/python-package/src/csm_tcp_router/models.py rename to SDK/python/src/csm_tcp_router/models.py diff --git a/SDK/python-package/tests/__init__.py b/SDK/python/tests/__init__.py similarity index 100% rename from SDK/python-package/tests/__init__.py rename to SDK/python/tests/__init__.py diff --git a/SDK/python-package/tests/conftest.py b/SDK/python/tests/conftest.py similarity index 100% rename from SDK/python-package/tests/conftest.py rename to SDK/python/tests/conftest.py diff --git a/SDK/python-package/tests/test_async_client.py b/SDK/python/tests/test_async_client.py similarity index 100% rename from SDK/python-package/tests/test_async_client.py rename to SDK/python/tests/test_async_client.py diff --git a/SDK/python-package/tests/test_client.py b/SDK/python/tests/test_client.py similarity index 100% rename from SDK/python-package/tests/test_client.py rename to SDK/python/tests/test_client.py diff --git a/SDK/python-package/tests/test_integration.py b/SDK/python/tests/test_integration.py similarity index 100% rename from SDK/python-package/tests/test_integration.py rename to SDK/python/tests/test_integration.py diff --git a/SDK/python-package/tests/test_protocol.py b/SDK/python/tests/test_protocol.py similarity index 100% rename from SDK/python-package/tests/test_protocol.py rename to SDK/python/tests/test_protocol.py From 279071b6f9ea3ff911f029df1773064012911816 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:44:14 +0800 Subject: [PATCH 05/10] Consolidate Python SDK into single-file `csm_tcp_router_client` module and release as v0.3.0 (#37) * Consolidate Python SDK into single-file csm_tcp_router_client module Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/ffc74aa4-b55d-45b4-b066-749d1db8c176 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * Bump SDK to 0.3.0 and fix workflow paths so publish jobs trigger Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/f2eec384-ae45-4817-bcb3-1f990db4954b Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --- .github/workflows/Python_SDK.yml | 12 +- SDK/python/CHANGELOG.md | 28 +- SDK/python/README.md | 4 +- SDK/python/README.zh-cn.md | 4 +- SDK/python/examples/async_usage.py | 3 +- SDK/python/examples/basic_usage.py | 3 +- SDK/python/examples/subscribe_status.py | 3 +- SDK/python/pyproject.toml | 9 +- SDK/python/src/csm_tcp_router/__init__.py | 58 - SDK/python/src/csm_tcp_router/_errors.py | 26 - SDK/python/src/csm_tcp_router/_protocol.py | 91 -- SDK/python/src/csm_tcp_router/_transport.py | 176 --- SDK/python/src/csm_tcp_router/async_client.py | 523 ------- SDK/python/src/csm_tcp_router/client.py | 456 ------ SDK/python/src/csm_tcp_router/exceptions.py | 45 - SDK/python/src/csm_tcp_router/models.py | 151 -- SDK/python/src/csm_tcp_router_client.py | 1316 +++++++++++++++++ SDK/python/tests/conftest.py | 3 +- SDK/python/tests/test_async_client.py | 19 +- SDK/python/tests/test_client.py | 18 +- SDK/python/tests/test_integration.py | 8 +- SDK/python/tests/test_protocol.py | 6 +- 22 files changed, 1386 insertions(+), 1576 deletions(-) delete mode 100644 SDK/python/src/csm_tcp_router/__init__.py delete mode 100644 SDK/python/src/csm_tcp_router/_errors.py delete mode 100644 SDK/python/src/csm_tcp_router/_protocol.py delete mode 100644 SDK/python/src/csm_tcp_router/_transport.py delete mode 100644 SDK/python/src/csm_tcp_router/async_client.py delete mode 100644 SDK/python/src/csm_tcp_router/client.py delete mode 100644 SDK/python/src/csm_tcp_router/exceptions.py delete mode 100644 SDK/python/src/csm_tcp_router/models.py create mode 100644 SDK/python/src/csm_tcp_router_client.py diff --git a/.github/workflows/Python_SDK.yml b/.github/workflows/Python_SDK.yml index 5711af5..d12bbda 100644 --- a/.github/workflows/Python_SDK.yml +++ b/.github/workflows/Python_SDK.yml @@ -3,17 +3,17 @@ name: Python SDK on: push: paths: - - 'SDK/python-package/**' + - 'SDK/python/**' tags: - 'python-sdk-v*' pull_request: paths: - - 'SDK/python-package/**' + - 'SDK/python/**' workflow_dispatch: defaults: run: - working-directory: SDK/python-package + working-directory: SDK/python jobs: # ------------------------------------------------------------------------- @@ -67,7 +67,7 @@ jobs: pip install pytest pytest-cov pytest-asyncio - name: Run tests - run: pytest --cov=csm_tcp_router --cov-report=term-missing + run: pytest --cov=csm_tcp_router_client --cov-report=term-missing # ------------------------------------------------------------------------- # Build (wheel + sdist) @@ -96,13 +96,13 @@ jobs: - name: Verify wheel is importable run: | pip install dist/*.whl - python -c "import csm_tcp_router; print('Version:', csm_tcp_router.__version__)" + 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-package/dist/ + path: SDK/python/dist/ # ------------------------------------------------------------------------- # Publish to TestPyPI (on tag push only – gates production publish) diff --git a/SDK/python/CHANGELOG.md b/SDK/python/CHANGELOG.md index a69cd48..2183933 100644 --- a/SDK/python/CHANGELOG.md +++ b/SDK/python/CHANGELOG.md @@ -11,6 +11,31 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm --- +## [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 @@ -79,6 +104,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - 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.2.0...HEAD +[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/README.md b/SDK/python/README.md index 1460803..16d3351 100644 --- a/SDK/python/README.md +++ b/SDK/python/README.md @@ -28,7 +28,7 @@ Requires Python 3.8 or later. No third-party dependencies—only the Python sta ### Synchronous client ```python -from csm_tcp_router import TcpRouterClient +from csm_tcp_router_client import TcpRouterClient with TcpRouterClient() as client: client.connect("localhost", 30007) @@ -49,7 +49,7 @@ with TcpRouterClient() as client: ```python import asyncio -from csm_tcp_router import AsyncTcpRouterClient +from csm_tcp_router_client import AsyncTcpRouterClient async def main(): async with AsyncTcpRouterClient() as client: diff --git a/SDK/python/README.zh-cn.md b/SDK/python/README.zh-cn.md index 49f3ef5..4e19ae1 100644 --- a/SDK/python/README.zh-cn.md +++ b/SDK/python/README.zh-cn.md @@ -28,7 +28,7 @@ pip install csm-tcp-router-client ### 同步客户端 ```python -from csm_tcp_router import TcpRouterClient +from csm_tcp_router_client import TcpRouterClient with TcpRouterClient() as client: client.connect("localhost", 30007) @@ -49,7 +49,7 @@ with TcpRouterClient() as client: ```python import asyncio -from csm_tcp_router import AsyncTcpRouterClient +from csm_tcp_router_client import AsyncTcpRouterClient async def main(): async with AsyncTcpRouterClient() as client: diff --git a/SDK/python/examples/async_usage.py b/SDK/python/examples/async_usage.py index 226023c..55a6e88 100644 --- a/SDK/python/examples/async_usage.py +++ b/SDK/python/examples/async_usage.py @@ -8,8 +8,7 @@ import asyncio -from csm_tcp_router import AsyncTcpRouterClient -from csm_tcp_router.models import StatusNotification +from csm_tcp_router_client import AsyncTcpRouterClient, StatusNotification async def on_status(notif: StatusNotification) -> None: diff --git a/SDK/python/examples/basic_usage.py b/SDK/python/examples/basic_usage.py index 3e07d5a..e53652e 100644 --- a/SDK/python/examples/basic_usage.py +++ b/SDK/python/examples/basic_usage.py @@ -15,8 +15,7 @@ """ -from csm_tcp_router import TcpRouterClient -from csm_tcp_router.exceptions import ConnectionError +from csm_tcp_router_client import ConnectionError, TcpRouterClient HOST = "localhost" PORT = 30007 diff --git a/SDK/python/examples/subscribe_status.py b/SDK/python/examples/subscribe_status.py index 0442467..165df6b 100644 --- a/SDK/python/examples/subscribe_status.py +++ b/SDK/python/examples/subscribe_status.py @@ -19,8 +19,7 @@ import threading import time -from csm_tcp_router import StatusNotification, TcpRouterClient -from csm_tcp_router.exceptions import ConnectionError, ServerError +from csm_tcp_router_client import ConnectionError, ServerError, StatusNotification, TcpRouterClient HOST = "localhost" PORT = 30007 diff --git a/SDK/python/pyproject.toml b/SDK/python/pyproject.toml index 3b77791..90ec5bb 100644 --- a/SDK/python/pyproject.toml +++ b/SDK/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "csm-tcp-router-client" -version = "0.2.0" +version = "0.3.0" description = "Python client SDK for the CSM-TCP-Router LabVIEW server" readme = "README.md" license = { text = "MIT" } @@ -43,11 +43,12 @@ classifiers = [ 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-package/README.md" -Changelog = "https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/blob/main/SDK/python-package/CHANGELOG.md" +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] -packages = ["src/csm_tcp_router"] +only-include = ["src/csm_tcp_router_client.py"] +sources = ["src"] # --------------------------------------------------------------------------- # Testing diff --git a/SDK/python/src/csm_tcp_router/__init__.py b/SDK/python/src/csm_tcp_router/__init__.py deleted file mode 100644 index 1a8d875..0000000 --- a/SDK/python/src/csm_tcp_router/__init__.py +++ /dev/null @@ -1,58 +0,0 @@ -"""csm-tcp-router-client – Python client SDK for the CSM-TCP-Router server. - -Sync usage:: - - from csm_tcp_router import TcpRouterClient - - with TcpRouterClient() as client: - client.connect("localhost", 30007) - print(client.list_modules()) - -Async usage:: - - import asyncio - from csm_tcp_router import AsyncTcpRouterClient - - async def main(): - async with AsyncTcpRouterClient() as client: - await client.connect("localhost", 30007) - print(await client.list_modules()) - - asyncio.run(main()) -""" - -from .async_client import AsyncTcpRouterClient -from .client import TcpRouterClient -from .exceptions import ( - ConnectionError, - ProtocolError, - ServerError, - TcpRouterError, - TimeoutError, -) -from .models import ( - AsyncResponse, - CommandResponse, - PacketType, - StatusNotification, -) - -__version__ = "0.2.0" - -__all__ = [ - "TcpRouterClient", - "AsyncTcpRouterClient", - # Exceptions - "TcpRouterError", - "ConnectionError", - "TimeoutError", - "ProtocolError", - "ServerError", - # Models - "PacketType", - "CommandResponse", - "AsyncResponse", - "StatusNotification", - # Version - "__version__", -] diff --git a/SDK/python/src/csm_tcp_router/_errors.py b/SDK/python/src/csm_tcp_router/_errors.py deleted file mode 100644 index 41c6017..0000000 --- a/SDK/python/src/csm_tcp_router/_errors.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Shared server-error parsing helper. - -Internal module – nothing is re-exported from the package. -""" - -from __future__ import annotations - -from .exceptions import ServerError -from .models import Packet - -__all__: list = [] # internal; nothing re-exported - - -def _parse_server_error(packet: Packet) -> ServerError: - """Extract code and message from a CSM Error format ``[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) diff --git a/SDK/python/src/csm_tcp_router/_protocol.py b/SDK/python/src/csm_tcp_router/_protocol.py deleted file mode 100644 index 4834ff0..0000000 --- a/SDK/python/src/csm_tcp_router/_protocol.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Internal protocol v0 codec. - -Wire format (8-byte header, big-endian):: - - | Data Length (4B) | Version (1B=0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | - ╰────────────────────────── Header (8B) ──────────────────────────╯ - -followed by exactly ``Data Length`` bytes of payload. - -This module is internal; nothing is re-exported from the package. -""" - -from __future__ import annotations - -import struct -from typing import Tuple - -from .exceptions import ProtocolError -from .models import Packet, PacketType - -__all__: list = [] # internal; nothing re-exported - -# Header layout: big-endian uint32 data_len + 4 x uint8 (version, type, flag1, flag2) -_HEADER_FORMAT = "!IBBBB" - -#: Number of bytes in the fixed packet header. -HEADER_SIZE: int = struct.calcsize(_HEADER_FORMAT) # == 8 - -#: Protocol version byte sent in every outgoing packet. -PROTOCOL_VERSION: int = 0x01 - - -def encode_packet( - data: bytes, - packet_type: PacketType, - flag1: int = 0, - flag2: int = 0, -) -> bytes: - """Encode *data* into a complete wire-format packet (header + body). - - :param data: Raw payload bytes. - :param packet_type: :class:`~csm_tcp_router.models.PacketType` for the header. - :param flag1: FLAG1 byte (currently unused; defaults to 0). - :param flag2: FLAG2 byte (currently unused; defaults to 0). - :returns: Concatenated header + payload bytes ready for ``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]: - """Decode an 8-byte header into its constituent fields. - - :returns: ``(data_len, version, type_byte, flag1, flag2)`` - :raises ProtocolError: if *header_bytes* is not exactly :data:`HEADER_SIZE` bytes. - """ - 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: - """Build a :class:`~csm_tcp_router.models.Packet` from raw header + body. - - Unknown packet type bytes are mapped to :attr:`PacketType.INFO` for - forward compatibility (the server may introduce new types in future - protocol revisions). - - :raises ProtocolError: on header size mismatch or body length mismatch. - """ - 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: - # Forward-compatible: treat unknown type as INFO - ptype = PacketType.INFO - return Packet(type=ptype, data=body, version=version, flag1=flag1, flag2=flag2) diff --git a/SDK/python/src/csm_tcp_router/_transport.py b/SDK/python/src/csm_tcp_router/_transport.py deleted file mode 100644 index 2de9938..0000000 --- a/SDK/python/src/csm_tcp_router/_transport.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Internal TCP transport layer with a background receive thread. - -This module is internal; nothing is re-exported from the package. -""" - -from __future__ import annotations - -import socket -import struct -import threading -from typing import Callable, Optional - -from ._protocol import HEADER_SIZE, parse_packet -from .exceptions import ConnectionError as RouterConnectionError -from .exceptions import ProtocolError -from .models import Packet - -__all__: list = [] # internal; nothing re-exported - - -class Transport: - """Thread-safe, blocking TCP transport. - - A background daemon thread continuously reads packets from the socket and - dispatches them via *on_packet*. Callers are responsible for keeping - callbacks fast and non-blocking, as they run in the receive thread. - - Lifecycle:: - - t = Transport(on_packet=..., on_disconnect=...) - t.connect("localhost", 30007) - t.send_raw(wire_bytes) - t.disconnect() - """ - - 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 - - # ------------------------------------------------------------------ - # Public interface - # ------------------------------------------------------------------ - - @property - def connected(self) -> bool: - """``True`` while the socket is open and the stop event has not fired.""" - 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: - """Open a TCP connection and start the receive thread. - - :param host: Target hostname or IP address. - :param port: Target TCP port. - :param timeout: Connect timeout in seconds. - :raises ConnectionError: if already connected or if the OS refuses. - """ - 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) # switch to blocking for the recv loop - 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: - """Close the connection and stop the receive thread. - - Safe to call even if not connected. - """ - 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: - """Send *data* atomically. Thread-safe. - - :raises ConnectionError: if not connected or if the send fails. - """ - 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 - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - def _recv_all(self, size: int) -> bytes: - """Read exactly *size* bytes; returns empty bytes on clean EOF or disconnect.""" - buf = bytearray(size) - view = memoryview(buf) - received = 0 - while received < size: - sock = self._sock # capture locally to avoid TOCTOU race with disconnect() - 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: - """Background thread: read packets and dispatch via callback.""" - try: - while not self._stop_event.is_set(): - header = self._recv_all(HEADER_SIZE) - if not header: - break - - # Extract data_len from the first 4 bytes without full decode - (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: - # Corrupted frame – skip it and keep the loop alive - continue - - self._on_packet(packet) - - except OSError: - pass - finally: - if not self._stop_event.is_set(): - self._stop_event.set() - self._on_disconnect() diff --git a/SDK/python/src/csm_tcp_router/async_client.py b/SDK/python/src/csm_tcp_router/async_client.py deleted file mode 100644 index de64c00..0000000 --- a/SDK/python/src/csm_tcp_router/async_client.py +++ /dev/null @@ -1,523 +0,0 @@ -"""Asyncio-based CSM-TCP-Router client.""" - -from __future__ import annotations - -import asyncio -import inspect -import struct -import time -from typing import Any, Callable, Coroutine, Dict, Optional, Tuple, Union - -from ._errors import _parse_server_error -from ._protocol import HEADER_SIZE, encode_packet, parse_packet -from .exceptions import ConnectionError as RouterConnectionError -from .exceptions import ProtocolError, ServerError -from .exceptions import TimeoutError as RouterTimeoutError -from .models import ( - AsyncResponse, - CommandResponse, - Packet, - PacketType, - StatusNotification, -) - -__all__ = ["AsyncTcpRouterClient"] - -# --------------------------------------------------------------------------- -# Callback type aliases – both plain callables and async coroutines are accepted -# --------------------------------------------------------------------------- - -_SyncStatusCb = Callable[[StatusNotification], None] -_AsyncStatusCb = Callable[[StatusNotification], "Coroutine[Any, Any, None]"] -StatusCallback = Union[_SyncStatusCb, _AsyncStatusCb] - -_SyncAsyncRespCb = Callable[[AsyncResponse], None] -_AsyncAsyncRespCb = Callable[[AsyncResponse], "Coroutine[Any, Any, None]"] -AsyncRespCallback = Union[_SyncAsyncRespCb, _AsyncAsyncRespCb] - -_SubKey = Tuple[str, str] - - -class AsyncTcpRouterClient: - """Asyncio client for a CSM-TCP-Router server. - - Provides the same interface as :class:`~csm_tcp_router.TcpRouterClient` but - as ``async def`` coroutines, suitable for use inside an asyncio event loop. - - **Quickstart**:: - - import asyncio - from csm_tcp_router 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:`~csm_tcp_router.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:`~csm_tcp_router.models.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:`~csm_tcp_router.models.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[StatusCallback]] = {} - 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. - - :param host: Server hostname or IP address. - :param port: Server TCP port (the reference app defaults to 30007). - :param timeout: Connection timeout in seconds. - :raises ConnectionError: if already connected or the OS refuses. - """ - 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:`~csm_tcp_router.exceptions.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. - - :param host: Server hostname or IP address. - :param port: Server TCP port. - :param timeout: Maximum time to wait in seconds. - :param retry_interval: Pause between retries in seconds. - :returns: ``True`` when the server is reachable; ``False`` on timeout. - """ - 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. - - Use the CSM synchronous suffix ``-@`` in *command*:: - - resp = await client.send_and_wait("API: Read -@ DAQmx") - print(resp.text) - - :param command: CSM command string. - :param timeout: Seconds to wait for the ``resp`` packet. - :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) - 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. - - Use the CSM async suffix ``->`` in *command*:: - - await client.post("API: Start Sampling -> DAQmx") - - :param command: CSM command string including the ``->`` suffix. - :param timeout: Seconds to wait for the ``cmd-resp`` handshake. - :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) - 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. - - Use the CSM no-reply suffix ``->|`` in *command*:: - - await client.post_no_reply("API: Reset ->| DAQmx") - - :param command: CSM command string including the ``->|`` suffix. - :param timeout: Seconds to wait for the ``cmd-resp`` handshake. - :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) - 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[StatusCallback] = None, - timeout: float = 5.0, - ) -> None: - """Subscribe to a CSM module's status broadcast. - - Sends ``"@ ->"`` and awaits the - ``cmd-resp`` handshake. Once subscribed, - :class:`~csm_tcp_router.models.StatusNotification` objects will be: - - * delivered to *callback* (if provided – sync or async both accepted), and - * added to :attr:`status_queue`. - - :param status_name: Name of the status (e.g. ``"Status"``). - :param module_name: Name of the CSM module (e.g. ``"AI"``). - :param callback: Optional callable or coroutine invoked per notification. - :param timeout: Seconds to wait for the ``cmd-resp`` handshake. - :raises ConnectionError: if not connected. - :raises TimeoutError: if no handshake arrives within *timeout*. - :raises ServerError: if the server rejects the subscription. - """ - # 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. - - :param status_name: Name of the subscribed status. - :param module_name: Name of the CSM module. - :param timeout: Seconds to wait for the ``cmd-resp`` handshake. - :raises ConnectionError: if not connected. - :raises TimeoutError: if no handshake arrives within *timeout*. - :raises ServerError: if the server rejects the request. - """ - 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. - - The callback is matched by *original_command* (the command text - echoed in the ``async-resp`` payload after the `` <- `` separator). - - Callbacks may be either a plain callable or an ``async def`` coroutine. - - :param original_command: The command text echoed in the ``async-resp``. - :param callback: Callable or coroutine receiving an - :class:`~csm_tcp_router.models.AsyncResponse`. - """ - 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/src/csm_tcp_router/client.py b/SDK/python/src/csm_tcp_router/client.py deleted file mode 100644 index 2f870d0..0000000 --- a/SDK/python/src/csm_tcp_router/client.py +++ /dev/null @@ -1,456 +0,0 @@ -"""High-level CSM-TCP-Router client.""" - -from __future__ import annotations - -import queue -import threading -import time -from typing import Callable, Dict, Optional, Tuple - -from ._errors import _parse_server_error -from ._protocol import encode_packet -from ._transport import Transport -from .exceptions import ConnectionError as RouterConnectionError -from .exceptions import ServerError -from .exceptions import TimeoutError as RouterTimeoutError -from .models import ( - AsyncResponse, - CommandResponse, - Packet, - PacketType, - StatusNotification, -) - -__all__ = ["TcpRouterClient"] - -# Type aliases -_SubKey = Tuple[str, str] -StatusCallback = Callable[[StatusNotification], None] -AsyncCallback = Callable[[AsyncResponse], None] - -# Items held in the internal queues are either Packet or Exception instances. -_QueueItem = object - - -class TcpRouterClient: - """Python client for a CSM-TCP-Router server. - - This class mirrors the LabVIEW ClientAPI VIs and speaks the - CSM-TCP-Router protocol v0. It is thread-safe in that its internal - state is protected by locks; however, the protocol allows at most one - in-flight *synchronous* command at a time and at most one in-flight - *async* command / subscription at a time. Concurrent callers are - serialised by ``_resp_lock`` and ``_cmd_resp_lock`` respectively. - - **Quickstart**:: - - from csm_tcp_router import TcpRouterClient - - with TcpRouterClient() as client: - client.connect("localhost", 30007) - print(client.list_modules()) - - **Protocol flows**: - - - *Synchronous* command (``-@``): :meth:`send_and_wait` – sends a ``CMD`` - packet and blocks until a ``RESP`` (or ``ERROR``) is received. - - *Asynchronous* command (``->``): :meth:`post` – sends a ``CMD`` packet - and blocks until the ``CMD_RESP`` handshake is received; the eventual - ``ASYNC_RESP`` is delivered asynchronously. - - *No-reply async* command (``->|``): :meth:`post_no_reply` – same as - :meth:`post` but no ``ASYNC_RESP`` will ever arrive. - - *Subscribe / unsubscribe*: :meth:`subscribe_status` / - :meth:`unsubscribe_status` – sends a ```` / ```` - command and waits for the ``CMD_RESP`` handshake. - - **Received-packet routing** (on the background receive thread): - - - ``RESP`` (0x04) – unblocks the caller of :meth:`send_and_wait`. - - ``CMD_RESP`` (0x03) – unblocks callers of :meth:`post`, - :meth:`post_no_reply`, :meth:`subscribe_status`, and - :meth:`unsubscribe_status`. - - ``ASYNC_RESP`` (0x05) – added to :attr:`async_response_queue` and - dispatched to any matching :meth:`register_async_callback`. - - ``STATUS`` / ``INTERRUPT`` (0x06 / 0x07) – added to - :attr:`status_queue` and dispatched to any matching - :meth:`subscribe_status` callback. - - ``ERROR`` (0x01) – unblocks any pending synchronous waiter with a - :exc:`~csm_tcp_router.exceptions.ServerError`. - - ``INFO`` (0x00) – silently discarded (welcome / goodbye messages). - """ - - def __init__(self) -> None: - self._transport = Transport( - on_packet=self._on_packet, - on_disconnect=self._on_disconnect, - ) - - # One-item-deep queues for synchronised waits. - # Items are either Packet or Exception instances. - self._resp_queue: queue.Queue[_QueueItem] = queue.Queue() - self._cmd_resp_queue: queue.Queue[_QueueItem] = queue.Queue() - - #: Polling queue for :class:`~csm_tcp_router.models.AsyncResponse` - #: objects received from the server. - self.async_response_queue: queue.Queue[AsyncResponse] = queue.Queue() - - #: Polling queue for :class:`~csm_tcp_router.models.StatusNotification` - #: objects received from the server. - self.status_queue: queue.Queue[StatusNotification] = queue.Queue() - - # Callback registries (protected by _lock) - self._status_callbacks: Dict[_SubKey, Optional[StatusCallback]] = {} - self._async_callbacks: Dict[str, AsyncCallback] = {} - self._lock = threading.Lock() - - # Serialisation locks – at most one in-flight RESP / CMD_RESP waiter - # at a time. This prevents concurrent callers from consuming each - # other's response packets. - 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:`~csm_tcp_router.exceptions.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. - - :param host: Server hostname or IP address. - :param port: Server TCP port. - :param timeout: Maximum time to wait in seconds. - :param retry_interval: Pause between retries in seconds. - :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. - - :param command: CSM command string. - :param timeout: Seconds to wait for the ``resp`` packet. - :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`. - - :param command: CSM command string including the ``->`` suffix. - :param timeout: Seconds to wait for the ``cmd-resp`` handshake. - :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. - - :param command: CSM command string including the ``->|`` suffix. - :param timeout: Seconds to wait for the ``cmd-resp`` handshake. - :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 ping(self, timeout: float = 2.0) -> Tuple[bool, float]: - """Send a ``Ping`` command and measure round-trip latency. - - :param timeout: Seconds to wait for the reply. - :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. - - Equivalent to the ``List`` router management command. - """ - 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:`~csm_tcp_router.models.StatusNotification` objects will be: - - * delivered to *callback* (if provided), and - * added to :attr:`status_queue`. - - :param status_name: Name of the status to subscribe to (e.g. ``"Status"``). - :param module_name: Name of the CSM module (e.g. ``"AI"``). - :param callback: Optional callable invoked on each notification. - Must be fast and non-blocking (runs in the recv thread). - :param timeout: Seconds to wait for the ``cmd-resp`` handshake. - :raises ConnectionError: if not connected. - :raises TimeoutError: if no handshake arrives within *timeout*. - :raises ServerError: if the server rejects the subscription. - """ - # 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. - - :param status_name: Name of the subscribed status. - :param module_name: Name of the CSM module. - :param timeout: Seconds to wait for the ``cmd-resp`` handshake. - :raises ConnectionError: if not connected. - :raises TimeoutError: if no handshake arrives within *timeout*. - :raises ServerError: if the server rejects the request. - """ - 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). - - :param original_command: The command text that will appear in the - ``async-resp`` echo - (e.g. ``"API: Read -> DAQmx"``). - :param callback: Callable receiving an - :class:`~csm_tcp_router.models.AsyncResponse`. - """ - 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 diff --git a/SDK/python/src/csm_tcp_router/exceptions.py b/SDK/python/src/csm_tcp_router/exceptions.py deleted file mode 100644 index e79ad48..0000000 --- a/SDK/python/src/csm_tcp_router/exceptions.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Exception hierarchy for csm-tcp-router-client.""" - -__all__ = [ - "ConnectionError", - "ProtocolError", - "ServerError", - "TcpRouterError", - "TimeoutError", -] - - -class TcpRouterError(Exception): - """Base exception for all CSM-TCP-Router client errors.""" - - -class ConnectionError(TcpRouterError): - """Raised when a connection cannot be established or is lost.""" - - -class TimeoutError(TcpRouterError): - """Raised when a synchronous operation exceeds its timeout.""" - - -class ProtocolError(TcpRouterError): - """Raised when an invalid or unexpected protocol frame is received.""" - - -class ServerError(TcpRouterError): - """Raised when the server returns an error packet. - - Attributes: - message: Human-readable error text from the server. - code: Optional error code extracted from the CSM Error format - ``[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 diff --git a/SDK/python/src/csm_tcp_router/models.py b/SDK/python/src/csm_tcp_router/models.py deleted file mode 100644 index 70ec0e2..0000000 --- a/SDK/python/src/csm_tcp_router/models.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Public data models and enumerations for the CSM-TCP-Router protocol.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import IntEnum - -__all__ = [ - "AsyncResponse", - "CommandResponse", - "Packet", - "PacketType", - "StatusNotification", -] - - -class PacketType(IntEnum): - """Packet type constants as defined in the CSM-TCP-Router protocol v0. - - Wire values - ----------- - ``INFO`` 0x00 – informational messages (welcome / goodbye) - ``ERROR`` 0x01 – error messages from the server - ``CMD`` 0x02 – command sent by the client - ``CMD_RESP`` 0x03 – server handshake for async / no-reply / subscribe - ``RESP`` 0x04 – synchronous response payload - ``ASYNC_RESP`` 0x05 – asynchronous response payload - ``STATUS`` 0x06 – status broadcast from a subscribed CSM module - ``INTERRUPT`` 0x07 – interrupt broadcast from a subscribed CSM module - """ - - INFO = 0x00 - ERROR = 0x01 - CMD = 0x02 - CMD_RESP = 0x03 - RESP = 0x04 - ASYNC_RESP = 0x05 - STATUS = 0x06 - INTERRUPT = 0x07 - - -@dataclass(frozen=True) -class Packet: - """A decoded packet received from the server (internal representation).""" - - type: PacketType - data: bytes - version: int = 1 - flag1: int = 0 - flag2: int = 0 - - -@dataclass(frozen=True) -class CommandResponse: - """The result of a synchronous command (:meth:`~csm_tcp_router.TcpRouterClient.send_and_wait`).""" - - raw: bytes - - @property - def text(self) -> str: - """Decoded UTF-8 text of the response payload.""" - return self.raw.decode("utf-8", errors="replace") - - def __repr__(self) -> str: - return f"CommandResponse({self.text!r})" - - -@dataclass(frozen=True) -class AsyncResponse: - """An asynchronous response payload delivered via an ``async-resp`` packet. - - Attributes: - raw: Raw response bytes (the part *before* the `` <- `` separator). - original_command: The original command text echoed back by the server - (the part *after* the `` <- `` separator). - """ - - raw: bytes - original_command: str = "" - - @property - def text(self) -> str: - """Decoded UTF-8 text of the response payload.""" - return self.raw.decode("utf-8", errors="replace") - - @classmethod - def from_packet(cls, packet: Packet) -> AsyncResponse: - """Parse an ``ASYNC_RESP`` packet. - - Server format: ``" <- "``. - """ - 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: - """A status broadcast delivered via a ``status`` or ``interrupt`` packet. - - Attributes: - raw: Full raw payload bytes. - packet_type: Either :attr:`PacketType.STATUS` or - :attr:`PacketType.INTERRUPT`. - status_name: The name of the broadcasted status (left of ``>>``). - data: The status payload (between ``>>`` and ``<-``). - module_name: The sending CSM module name (right of ``<-``). - """ - - raw: bytes - packet_type: PacketType = PacketType.STATUS - status_name: str = "" - data: str = "" - module_name: str = "" - - @classmethod - def from_packet(cls, packet: Packet) -> StatusNotification: - """Parse a ``STATUS`` or ``INTERRUPT`` packet. - - Server format: ``" >> <- "``. - """ - 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})" - ) 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..4058fab --- /dev/null +++ b/SDK/python/src/csm_tcp_router_client.py @@ -0,0 +1,1316 @@ +"""csm-tcp-router-client – single-file Python client SDK for the CSM-TCP-Router server. + +This module bundles the entire client implementation (sync and async) along +with the wire-protocol codec, exception hierarchy and public data models +into a single importable file. + +Sync usage:: + + from csm_tcp_router_client import TcpRouterClient + + with TcpRouterClient() as client: + client.connect("localhost", 30007) + print(client.list_modules()) + +Async usage:: + + 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()) + +Wire format (8-byte header, big-endian):: + + | Data Length (4B) | Version (1B=0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | + ╰────────────────────────── Header (8B) ──────────────────────────╯ + +followed by exactly ``Data Length`` bytes of payload. +""" + +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__ = [ + # Clients + "TcpRouterClient", + "AsyncTcpRouterClient", + # Exceptions + "TcpRouterError", + "ConnectionError", + "TimeoutError", + "ProtocolError", + "ServerError", + # Models + "PacketType", + "Packet", + "CommandResponse", + "AsyncResponse", + "StatusNotification", + # Version + "__version__", +] + +__version__ = "0.3.0" + + +# =========================================================================== +# Exceptions +# =========================================================================== + + +class TcpRouterError(Exception): + """Base exception for all CSM-TCP-Router client errors.""" + + +class ConnectionError(TcpRouterError): + """Raised when a connection cannot be established or is lost.""" + + +class TimeoutError(TcpRouterError): + """Raised when a synchronous operation exceeds its timeout.""" + + +class ProtocolError(TcpRouterError): + """Raised when an invalid or unexpected protocol frame is received.""" + + +class ServerError(TcpRouterError): + """Raised when the server returns an error packet. + + Attributes: + message: Human-readable error text from the server. + code: Optional error code extracted from the CSM Error format + ``[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 + + +# Internal aliases used by the transport / receive code below to avoid +# ambiguity with the module-level shadowed builtins. +_RouterConnectionError = ConnectionError +_RouterTimeoutError = TimeoutError + + +# =========================================================================== +# Public data models +# =========================================================================== + + +class PacketType(IntEnum): + """Packet type constants as defined in the CSM-TCP-Router protocol v0. + + Wire values + ----------- + ``INFO`` 0x00 – informational messages (welcome / goodbye) + ``ERROR`` 0x01 – error messages from the server + ``CMD`` 0x02 – command sent by the client + ``CMD_RESP`` 0x03 – server handshake for async / no-reply / subscribe + ``RESP`` 0x04 – synchronous response payload + ``ASYNC_RESP`` 0x05 – asynchronous response payload + ``STATUS`` 0x06 – status broadcast from a subscribed CSM module + ``INTERRUPT`` 0x07 – interrupt broadcast from a subscribed CSM module + """ + + INFO = 0x00 + ERROR = 0x01 + CMD = 0x02 + CMD_RESP = 0x03 + RESP = 0x04 + ASYNC_RESP = 0x05 + STATUS = 0x06 + INTERRUPT = 0x07 + + +@dataclass(frozen=True) +class Packet: + """A decoded packet received from the server (internal representation).""" + + type: PacketType + data: bytes + version: int = 1 + flag1: int = 0 + flag2: int = 0 + + +@dataclass(frozen=True) +class CommandResponse: + """The result of a synchronous command (:meth:`TcpRouterClient.send_and_wait`).""" + + raw: bytes + + @property + def text(self) -> str: + """Decoded UTF-8 text of the response payload.""" + return self.raw.decode("utf-8", errors="replace") + + def __repr__(self) -> str: + return f"CommandResponse({self.text!r})" + + +@dataclass(frozen=True) +class AsyncResponse: + """An asynchronous response payload delivered via an ``async-resp`` packet. + + Attributes: + raw: Raw response bytes (the part *before* the `` <- `` separator). + original_command: The original command text echoed back by the server + (the part *after* the `` <- `` separator). + """ + + raw: bytes + original_command: str = "" + + @property + def text(self) -> str: + """Decoded UTF-8 text of the response payload.""" + return self.raw.decode("utf-8", errors="replace") + + @classmethod + def from_packet(cls, packet: Packet) -> AsyncResponse: + """Parse an ``ASYNC_RESP`` packet. + + Server format: ``" <- "``. + """ + 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: + """A status broadcast delivered via a ``status`` or ``interrupt`` packet. + + Attributes: + raw: Full raw payload bytes. + packet_type: Either :attr:`PacketType.STATUS` or + :attr:`PacketType.INTERRUPT`. + status_name: The name of the broadcasted status (left of ``>>``). + data: The status payload (between ``>>`` and ``<-``). + module_name: The sending CSM module name (right of ``<-``). + """ + + raw: bytes + packet_type: PacketType = PacketType.STATUS + status_name: str = "" + data: str = "" + module_name: str = "" + + @classmethod + def from_packet(cls, packet: Packet) -> StatusNotification: + """Parse a ``STATUS`` or ``INTERRUPT`` packet. + + Server format: ``" >> <- "``. + """ + 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})" + ) + + +# =========================================================================== +# Protocol codec (internal but importable for advanced use / testing) +# =========================================================================== + +# Header layout: big-endian uint32 data_len + 4 x uint8 (version, type, flag1, flag2) +_HEADER_FORMAT = "!IBBBB" + +#: Number of bytes in the fixed packet header. +HEADER_SIZE: int = struct.calcsize(_HEADER_FORMAT) # == 8 + +#: Protocol version byte sent in every outgoing packet. +PROTOCOL_VERSION: int = 0x01 + + +def encode_packet( + data: bytes, + packet_type: PacketType, + flag1: int = 0, + flag2: int = 0, +) -> bytes: + """Encode *data* into a complete wire-format packet (header + body). + + :param data: Raw payload bytes. + :param packet_type: :class:`PacketType` for the header. + :param flag1: FLAG1 byte (currently unused; defaults to 0). + :param flag2: FLAG2 byte (currently unused; defaults to 0). + :returns: Concatenated header + payload bytes ready for ``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]: + """Decode an 8-byte header into its constituent fields. + + :returns: ``(data_len, version, type_byte, flag1, flag2)`` + :raises ProtocolError: if *header_bytes* is not exactly :data:`HEADER_SIZE` bytes. + """ + 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: + """Build a :class:`Packet` from raw header + body. + + Unknown packet type bytes are mapped to :attr:`PacketType.INFO` for + forward compatibility (the server may introduce new types in future + protocol revisions). + + :raises ProtocolError: on header size mismatch or body length mismatch. + """ + 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: + # Forward-compatible: treat unknown type as INFO + ptype = PacketType.INFO + return Packet(type=ptype, data=body, version=version, flag1=flag1, flag2=flag2) + + +# =========================================================================== +# Shared server-error parsing helper +# =========================================================================== + + +def _parse_server_error(packet: Packet) -> ServerError: + """Extract code and message from a CSM Error format ``[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) + + +# =========================================================================== +# Internal: thread-based TCP transport (used by the sync client) +# =========================================================================== + + +class _Transport: + """Thread-safe, blocking TCP transport. + + A background daemon thread continuously reads packets from the socket and + dispatches them via *on_packet*. Callers are responsible for keeping + callbacks fast and non-blocking, as they run in the receive thread. + """ + + 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`` while the socket is open and the stop event has not fired.""" + 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: + """Open a TCP connection and start the receive thread.""" + 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) # switch to blocking for the recv loop + 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: + """Close the connection and stop the receive thread.""" + 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: + """Send *data* atomically. Thread-safe.""" + 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: + """Read exactly *size* bytes; returns empty bytes on clean EOF or disconnect.""" + buf = bytearray(size) + view = memoryview(buf) + received = 0 + while received < size: + sock = self._sock # capture locally to avoid TOCTOU race with disconnect() + 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: + """Background thread: read packets and dispatch via callback.""" + try: + while not self._stop_event.is_set(): + header = self._recv_all(HEADER_SIZE) + if not header: + break + + # Extract data_len from the first 4 bytes without full decode + (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: + # Corrupted frame – skip it and keep the loop alive + continue + + self._on_packet(packet) + + except OSError: + pass + finally: + if not self._stop_event.is_set(): + self._stop_event.set() + self._on_disconnect() + + +# =========================================================================== +# TcpRouterClient – thread-based synchronous client +# =========================================================================== + +# Type aliases +_SubKey = Tuple[str, str] +StatusCallback = Callable[[StatusNotification], None] +AsyncCallback = Callable[[AsyncResponse], None] + +# Items held in the internal queues are either Packet or Exception instances. +_QueueItem = object + + +class TcpRouterClient: + """Python client for a CSM-TCP-Router server. + + This class mirrors the LabVIEW ClientAPI VIs and speaks the + CSM-TCP-Router protocol v0. It is thread-safe in that its internal + state is protected by locks; however, the protocol allows at most one + in-flight *synchronous* command at a time and at most one in-flight + *async* command / subscription at a time. Concurrent callers are + serialised by ``_resp_lock`` and ``_cmd_resp_lock`` respectively. + + **Quickstart**:: + + from csm_tcp_router_client import TcpRouterClient + + with TcpRouterClient() as client: + client.connect("localhost", 30007) + print(client.list_modules()) + + **Protocol flows**: + + - *Synchronous* command (``-@``): :meth:`send_and_wait` – sends a ``CMD`` + packet and blocks until a ``RESP`` (or ``ERROR``) is received. + - *Asynchronous* command (``->``): :meth:`post` – sends a ``CMD`` packet + and blocks until the ``CMD_RESP`` handshake is received; the eventual + ``ASYNC_RESP`` is delivered asynchronously. + - *No-reply async* command (``->|``): :meth:`post_no_reply` – same as + :meth:`post` but no ``ASYNC_RESP`` will ever arrive. + - *Subscribe / unsubscribe*: :meth:`subscribe_status` / + :meth:`unsubscribe_status` – sends a ```` / ```` + command and waits for the ``CMD_RESP`` handshake. + + **Received-packet routing** (on the background receive thread): + + - ``RESP`` (0x04) – unblocks the caller of :meth:`send_and_wait`. + - ``CMD_RESP`` (0x03) – unblocks callers of :meth:`post`, + :meth:`post_no_reply`, :meth:`subscribe_status`, and + :meth:`unsubscribe_status`. + - ``ASYNC_RESP`` (0x05) – added to :attr:`async_response_queue` and + dispatched to any matching :meth:`register_async_callback`. + - ``STATUS`` / ``INTERRUPT`` (0x06 / 0x07) – added to + :attr:`status_queue` and dispatched to any matching + :meth:`subscribe_status` callback. + - ``ERROR`` (0x01) – unblocks any pending synchronous waiter with a + :exc:`ServerError`. + - ``INFO`` (0x00) – silently discarded (welcome / goodbye messages). + """ + + def __init__(self) -> None: + self._transport = _Transport( + on_packet=self._on_packet, + on_disconnect=self._on_disconnect, + ) + + # One-item-deep queues for synchronised waits. + # Items are either Packet or Exception instances. + self._resp_queue: queue.Queue[_QueueItem] = queue.Queue() + self._cmd_resp_queue: queue.Queue[_QueueItem] = queue.Queue() + + #: Polling queue for :class:`AsyncResponse` objects received from the server. + self.async_response_queue: queue.Queue[AsyncResponse] = queue.Queue() + + #: Polling queue for :class:`StatusNotification` objects received + #: from the server. + self.status_queue: queue.Queue[StatusNotification] = queue.Queue() + + # Callback registries (protected by _lock) + self._status_callbacks: Dict[_SubKey, Optional[StatusCallback]] = {} + self._async_callbacks: Dict[str, AsyncCallback] = {} + self._lock = threading.Lock() + + # Serialisation locks – at most one in-flight RESP / CMD_RESP waiter + # at a time. This prevents concurrent callers from consuming each + # other's response packets. + 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/conftest.py b/SDK/python/tests/conftest.py index 8055db0..ae07cc6 100644 --- a/SDK/python/tests/conftest.py +++ b/SDK/python/tests/conftest.py @@ -10,8 +10,7 @@ import pytest -from csm_tcp_router._protocol import HEADER_SIZE, encode_packet -from csm_tcp_router.models import PacketType +from csm_tcp_router_client import HEADER_SIZE, PacketType, encode_packet # --------------------------------------------------------------------------- # Internal helpers diff --git a/SDK/python/tests/test_async_client.py b/SDK/python/tests/test_async_client.py index 7047ec2..07be0c0 100644 --- a/SDK/python/tests/test_async_client.py +++ b/SDK/python/tests/test_async_client.py @@ -8,18 +8,17 @@ import pytest -from csm_tcp_router import AsyncTcpRouterClient -from csm_tcp_router.async_client import _parse_server_error -from csm_tcp_router.exceptions import ( - ConnectionError as RouterConnectionError, -) -from csm_tcp_router.exceptions import ( +from csm_tcp_router_client import ( + AsyncResponse, + AsyncTcpRouterClient, + Packet, + PacketType, ServerError, + StatusNotification, + _parse_server_error, ) -from csm_tcp_router.exceptions import ( - TimeoutError as RouterTimeoutError, -) -from csm_tcp_router.models import AsyncResponse, Packet, PacketType, StatusNotification +from csm_tcp_router_client import ConnectionError as RouterConnectionError +from csm_tcp_router_client import TimeoutError as RouterTimeoutError # --------------------------------------------------------------------------- # Helpers diff --git a/SDK/python/tests/test_client.py b/SDK/python/tests/test_client.py index 4f824df..14cb2b9 100644 --- a/SDK/python/tests/test_client.py +++ b/SDK/python/tests/test_client.py @@ -9,17 +9,17 @@ import pytest -from csm_tcp_router.client import TcpRouterClient, _parse_server_error -from csm_tcp_router.exceptions import ( - ConnectionError as RouterConnectionError, -) -from csm_tcp_router.exceptions import ( +from csm_tcp_router_client import ( + AsyncResponse, + Packet, + PacketType, ServerError, + StatusNotification, + TcpRouterClient, + _parse_server_error, ) -from csm_tcp_router.exceptions import ( - TimeoutError as RouterTimeoutError, -) -from csm_tcp_router.models import AsyncResponse, Packet, PacketType, StatusNotification +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 diff --git a/SDK/python/tests/test_integration.py b/SDK/python/tests/test_integration.py index 563dfd1..86cf964 100644 --- a/SDK/python/tests/test_integration.py +++ b/SDK/python/tests/test_integration.py @@ -7,10 +7,8 @@ import pytest -from csm_tcp_router import TcpRouterClient -from csm_tcp_router.exceptions import ServerError -from csm_tcp_router.exceptions import TimeoutError as RouterTimeoutError -from csm_tcp_router.models import StatusNotification +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. @@ -35,7 +33,7 @@ def test_context_manager(self, mock_server): def test_connect_bad_port_raises(self): client = TcpRouterClient() - from csm_tcp_router.exceptions import ConnectionError as RouterConnectionError + from csm_tcp_router_client import ConnectionError as RouterConnectionError with pytest.raises(RouterConnectionError): client.connect("127.0.0.1", 1, timeout=0.5) diff --git a/SDK/python/tests/test_protocol.py b/SDK/python/tests/test_protocol.py index bb5c12e..41d5223 100644 --- a/SDK/python/tests/test_protocol.py +++ b/SDK/python/tests/test_protocol.py @@ -6,15 +6,15 @@ import pytest -from csm_tcp_router._protocol import ( +from csm_tcp_router_client import ( HEADER_SIZE, PROTOCOL_VERSION, + PacketType, + ProtocolError, decode_header, encode_packet, parse_packet, ) -from csm_tcp_router.exceptions import ProtocolError -from csm_tcp_router.models import PacketType # --------------------------------------------------------------------------- # encode_packet From 11937394212a39013e37d2cb38dacb7694857602 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:21:22 +0800 Subject: [PATCH 06/10] Add C# csm-tcp-router-client SDK (#38) * Initial plan * Add C# csm-tcp-router-client SDK (single-file), VS solution, xUnit tests, example, NuGet packaging, and GitHub Actions workflow Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/1c9d260f-6983-4c60-9d7a-f07538723b70 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * Address review: deterministic closed-port helper, ManualResetEventSlim in unsubscribe test, observe WaitForServerAsync connect task, force disconnect on RESP/CMD_RESP timeout Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/a52165ab-7829-42b0-91f9-e4ba84e0b6b9 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --- .github/workflows/CSharp_SDK.yml | 124 +++ .gitignore | 23 +- SDK/csharp/CHANGELOG.md | 40 + SDK/csharp/CsmTcpRouter.sln | 73 ++ SDK/csharp/LICENSE | 21 + SDK/csharp/README.md | 189 ++++ SDK/csharp/README.zh-cn.md | 189 ++++ .../examples/BasicUsage/BasicUsage.csproj | 17 + SDK/csharp/examples/BasicUsage/Program.cs | 91 ++ SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs | 924 ++++++++++++++++++ .../src/CsmTcpRouter/CsmTcpRouter.csproj | 51 + .../ClientIntegrationTests.cs | 364 +++++++ .../CsmTcpRouter.Tests.csproj | 28 + .../tests/CsmTcpRouter.Tests/MockServer.cs | 218 +++++ .../tests/CsmTcpRouter.Tests/ProtocolTests.cs | 306 ++++++ 15 files changed, 2657 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/CSharp_SDK.yml create mode 100644 SDK/csharp/CHANGELOG.md create mode 100644 SDK/csharp/CsmTcpRouter.sln create mode 100644 SDK/csharp/LICENSE create mode 100644 SDK/csharp/README.md create mode 100644 SDK/csharp/README.zh-cn.md create mode 100644 SDK/csharp/examples/BasicUsage/BasicUsage.csproj create mode 100644 SDK/csharp/examples/BasicUsage/Program.cs create mode 100644 SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs create mode 100644 SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.csproj create mode 100644 SDK/csharp/tests/CsmTcpRouter.Tests/ClientIntegrationTests.cs create mode 100644 SDK/csharp/tests/CsmTcpRouter.Tests/CsmTcpRouter.Tests.csproj create mode 100644 SDK/csharp/tests/CsmTcpRouter.Tests/MockServer.cs create mode 100644 SDK/csharp/tests/CsmTcpRouter.Tests/ProtocolTests.cs 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/.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/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..337b5e9 --- /dev/null +++ b/SDK/csharp/CsmTcpRouter.sln @@ -0,0 +1,73 @@ + +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 +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 + 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} + 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..7384c05 --- /dev/null +++ b/SDK/csharp/examples/BasicUsage/Program.cs @@ -0,0 +1,91 @@ +using System; +using CsmTcpRouter; + +namespace CsmTcpRouter.Examples.BasicUsage +{ + ///

+ /// Basic usage example for csm-tcp-router-client (C#). + /// Mirrors SDK/python/examples/basic_usage.py. + /// + /// Prerequisites: a running CSM-TCP-Router server (LabVIEW app). + /// The reference app defaults to port 30007. + /// + public static class Program + { + private const string Host = "localhost"; + private const int Port = 30007; + + public static int Main(string[] args) + { + // 1. Wait until the server is ready. + 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. Connect (use as IDisposable so Disconnect is always called). + 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. List CSM modules + string modules = client.ListModules(); + Console.WriteLine($"\nLoaded modules:\n{modules}"); + + // 5. List API for the first module (if any) + 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. Send a synchronous command (uncomment & adapt for your CSM): + // var resp = client.SendAndWait("API: Read -@ DAQmx"); + // Console.WriteLine($"\nSync response: {resp.Text}"); + + // 7. Send an async command + wait for cmd-resp handshake: + // client.Post("API: Start Sampling -> DAQmx"); + + // 8. Send a no-reply command: + // client.PostNoReply("API: Reset ->| DAQmx"); + + Console.WriteLine("\nDone."); + } + Console.WriteLine("Disconnected."); + return 0; + } + } +} diff --git a/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs b/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs new file mode 100644 index 0000000..1a73673 --- /dev/null +++ b/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs @@ -0,0 +1,924 @@ +// CsmTcpRouter.cs +// --------------------------------------------------------------------------- +// csm-tcp-router-client - C# client SDK for the CSM-TCP-Router LabVIEW server. +// +// Single-file SDK implementing CSM-TCP-Router protocol v0. Mirrors the +// Python `csm_tcp_router` package layout and feature set: +// +// * Protocol codec (8-byte header, big-endian, 8 packet types). +// * Background-receiver TCP transport. +// * High-level TcpRouterClient with sync and async APIs: +// - SendAndWait / SendAndWaitAsync (synchronous CMD/RESP) +// - Post / PostAsync (async CMD with cmd-resp handshake) +// - PostNoReply / PostNoReplyAsync (no-reply async CMD) +// - Ping / PingAsync (round-trip latency) +// - ListModules / ListApi / ListStates / Help +// - SubscribeStatus / UnsubscribeStatus +// - RegisterAsyncCallback / UnregisterAsyncCallback +// +// Wire format (8-byte header, big-endian):: +// +// | Data Length (4B) | Version (1B=0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | +// +------------------------ Header (8B) -----------------------+ +// +// followed by exactly `Data Length` bytes of payload. +// +// 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 +{ + // ----------------------------------------------------------------------- + // Public enumerations + // ----------------------------------------------------------------------- + + /// + /// Packet type constants as defined in the CSM-TCP-Router protocol v0. + /// + public enum PacketType : byte + { + /// Informational message (welcome / goodbye). + Info = 0x00, + /// Error packet from the server. + Error = 0x01, + /// Command sent by the client. + Cmd = 0x02, + /// Server handshake for async / no-reply / subscribe. + CmdResp = 0x03, + /// Synchronous response payload. + Resp = 0x04, + /// Asynchronous response payload. + AsyncResp = 0x05, + /// Status broadcast from a subscribed CSM module. + Status = 0x06, + /// Interrupt broadcast from a subscribed CSM module. + Interrupt = 0x07, + } + + // ----------------------------------------------------------------------- + // Public data models + // ----------------------------------------------------------------------- + + /// A decoded packet received from the server. + 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; + } + } + + /// The result of a synchronous command (). + 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}\")"; + } + + /// An asynchronous response payload delivered via an async-resp packet. + 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; + } + + /// + /// Parse an ASYNC_RESP packet. Server format: + /// "<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}\")"; + } + + /// A status broadcast delivered via a STATUS or INTERRUPT packet. + 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; + } + + /// + /// Parse a STATUS or INTERRUPT packet. Server format: + /// "<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}\")"; + } + + // ----------------------------------------------------------------------- + // Exception hierarchy + // ----------------------------------------------------------------------- + + /// Base exception for all CSM-TCP-Router client errors. + public class CsmTcpRouterException : Exception + { + public CsmTcpRouterException() { } + public CsmTcpRouterException(string message) : base(message) { } + public CsmTcpRouterException(string message, Exception innerException) : base(message, innerException) { } + } + + /// Raised when a connection cannot be established or is lost. + public class RouterConnectionException : CsmTcpRouterException + { + public RouterConnectionException(string message) : base(message) { } + public RouterConnectionException(string message, Exception innerException) : base(message, innerException) { } + } + + /// Raised when a synchronous operation exceeds its timeout. + public class RouterTimeoutException : CsmTcpRouterException + { + public RouterTimeoutException(string message) : base(message) { } + } + + /// Raised when an invalid or unexpected protocol frame is received. + public class ProtocolException : CsmTcpRouterException + { + public ProtocolException(string message) : base(message) { } + } + + /// + /// Raised when the server returns an error packet. CSM Error format: + /// [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 protocol codec + // ----------------------------------------------------------------------- + + internal static class ProtocolCodec + { + public const int HeaderSize = 8; + public const byte ProtocolVersion = 0x01; + + /// Encode into a complete wire-format packet (header + body). + 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; + } + + /// Decode an 8-byte header into its constituent fields. + 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]); + } + + /// Build a from raw header + body. + 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."); + // Forward-compatible: unknown type bytes are mapped to Info. + PacketType ptype = Enum.IsDefined(typeof(PacketType), typeByte) + ? (PacketType)typeByte + : PacketType.Info; + return new Packet(ptype, body, version, flag1, flag2); + } + + /// Extract code and message from a CSM Error format [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); + } + } + + // ----------------------------------------------------------------------- + // Internal TCP transport (background receive task) + // ----------------------------------------------------------------------- + + 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 { /* ignore */ } + throw new RouterConnectionException( + $"Cannot connect to {host}:{port}: timed out after {to.TotalSeconds:F1}s."); + } + await connectTask.ConfigureAwait(false); // surface any connect exception + } + catch (RouterConnectionException) + { + throw; + } + catch (Exception exc) + { + try { client.Close(); } catch { /* ignore */ } + 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 { /* ignore */ } + try { _stream?.Close(); } catch { /* ignore */ } + try { _client?.Close(); } catch { /* ignore */ } + _stream = null; + _client = null; + var jt = joinTimeout ?? TimeSpan.FromSeconds(2); + try { _recvTask?.Wait(jt); } catch { /* ignore */ } + _recvTask = null; + try { _cts?.Dispose(); } catch { /* ignore */ } + _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(); + + // --------------------------------------------------------------- + // Internal: background receive loop + // --------------------------------------------------------------- + + 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) + { + // Corrupted frame -- skip it and keep the loop alive. + continue; + } + + try { _onPacket(packet); } catch { /* swallow callback errors */ } + } + } + catch (IOException) { /* connection dropped */ } + catch (ObjectDisposedException) { /* socket closed during read */ } + catch (OperationCanceledException) { /* shutdown */ } + finally + { + if (!_stopped) + { + _stopped = true; + try { _onDisconnect(); } catch { /* ignore */ } + } + } + } + + 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; + } + } + + // ----------------------------------------------------------------------- + // High-level client + // ----------------------------------------------------------------------- + + /// Callback delegate for status / interrupt broadcasts. + public delegate void StatusCallback(StatusNotification notification); + + /// Callback delegate for asynchronous-response packets. + public delegate void AsyncResponseCallback(AsyncResponse response); + + /// + /// C# client for a CSM-TCP-Router server. Mirrors the LabVIEW ClientAPI + /// VIs and the Python TcpRouterClient; speaks protocol v0. + /// + /// The class is thread-safe. At most one in-flight synchronous command + /// and one in-flight async / subscription command may be outstanding at a + /// time; concurrent callers are serialised by internal semaphores. + /// + public sealed class TcpRouterClient : IDisposable + { + // One-item-deep "queues" for synchronised waits, implemented via TCS. + // Reset to a fresh TCS by each waiter inside the corresponding lock. + 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; + + /// Polling queue for async-resp packets received from the server. + public ConcurrentQueue AsyncResponseQueue { get; } = new ConcurrentQueue(); + + /// Polling queue for status / interrupt notifications. + public ConcurrentQueue StatusQueue { get; } = new ConcurrentQueue(); + + public TcpRouterClient() + { + _transport = new Transport(OnPacket, OnDisconnect); + } + + // --------------------------------------------------------------- + // Connection management + // --------------------------------------------------------------- + + /// Connect to a CSM-TCP-Router server. + public void Connect(string host, int port, TimeSpan? timeout = null) + => _transport.Connect(host, port, timeout); + + /// Connect to a CSM-TCP-Router server (async). + public Task ConnectAsync(string host, int port, TimeSpan? timeout = null) + => _transport.ConnectAsync(host, port, timeout); + + /// + /// Disconnect from the server and release all resources. Any threads + /// currently blocked in / + /// will receive a immediately + /// rather than waiting for their timeout to expire. + /// + public void Disconnect() + { + // Unblock any pending waiters before tearing down the transport. + var sentinel = new RouterConnectionException("Disconnected from server."); + UnblockWaiters(sentinel); + _transport.Disconnect(); + } + + /// true when the underlying transport is connected. + public bool Connected => _transport.Connected; + + /// + /// Poll until : accepts + /// a connection or elapses. + /// + public bool WaitForServer(string host, int port, TimeSpan? timeout = null, TimeSpan? retryInterval = null) + => WaitForServerAsync(host, port, timeout, retryInterval).GetAwaiter().GetResult(); + + /// Async version of . + 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 + { + // Observe any connect exception (faulted task); + // success means the server is reachable. + await connectTask.ConfigureAwait(false); + try { probe.Close(); } catch { /* ignore */ } + return true; + } + catch (SocketException) { /* not ready yet */ } + catch (IOException) { /* not ready yet */ } + } + else + { + // Delay won; abort the in-flight connect attempt by + // closing the probe socket, then observe any pending + // exception so it is not unobserved. + try { probe.Close(); } catch { /* ignore */ } + try + { + await connectTask.ConfigureAwait(false); + } + catch (SocketException) { /* not ready yet */ } + catch (IOException) { /* not ready yet */ } + catch (ObjectDisposedException) { /* connect aborted by closing probe */ } + } + } + await Task.Delay(interval).ConfigureAwait(false); + } + return false; + } + + // --------------------------------------------------------------- + // Core command methods (sync wrappers) + // --------------------------------------------------------------- + + 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(); + + // --------------------------------------------------------------- + // Core command methods (async) + // --------------------------------------------------------------- + + /// + /// Send a synchronous command (suffix -@) and wait for the response. + /// + 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(); + } + } + + /// + /// Send an async command (suffix ->) and wait for the cmd-resp handshake. + /// + public Task PostAsync(string command, TimeSpan? timeout = null) + => SendAndAwaitCmdRespAsync(command, timeout); + + /// + /// Send an async no-reply command (suffix ->|) and wait for the cmd-resp handshake. + /// + public Task PostNoReplyAsync(string command, TimeSpan? timeout = null) + => SendAndAwaitCmdRespAsync(command, timeout); + + /// Send a Ping and measure round-trip latency. + 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(); + + /// Subscribe to a CSM module's status broadcast. + 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); + // Register the callback *before* sending to eliminate the race + // where a STATUS packet could arrive before the callback is stored. + lock (_stateLock) { _statusCallbacks[key] = callback; } + + string cmd = $"{statusName}@{moduleName} ->"; + try + { + await SendAndAwaitCmdRespAsync(cmd, timeout).ConfigureAwait(false); + } + catch + { + lock (_stateLock) { _statusCallbacks.Remove(key); } + throw; + } + } + + /// Cancel a status subscription. + 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)); } + } + + /// + /// Register a callback for async-resp packets, matched by the original + /// command echoed in the async-resp payload (after the <- separator). + /// + 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; } + } + + /// Remove a previously registered async callback. + public void UnregisterAsyncCallback(string originalCommand) + { + if (originalCommand == null) return; + lock (_stateLock) { _asyncCallbacks.Remove(originalCommand); } + } + + // --------------------------------------------------------------- + // IDisposable + // --------------------------------------------------------------- + + public void Dispose() + { + Disconnect(); + _respLock.Dispose(); + _cmdRespLock.Dispose(); + } + + // --------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------- + + 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) + { + // Protocol v0 has no correlation id, so a late RESP for the + // timed-out command could be misattributed to the *next* + // SendAndWait call. Force a disconnect so the connection + // is unusable until the caller reconnects. + try { _transport.Disconnect(); } catch { /* ignore */ } + 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) + { + // Same desync risk as WaitForRespAsync: a late CMD_RESP could + // complete the next in-flight waiter. Force a disconnect so + // the connection cannot be reused after a handshake timeout. + try { _transport.Disconnect(); } catch { /* ignore */ } + 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 payload is a handshake acknowledgment; discard it. + } + + private void UnblockWaiters(Exception sentinel) + { + _respTcs?.TrySetResult(sentinel); + _cmdRespTcs?.TrySetResult(sentinel); + } + + // --------------------------------------------------------------- + // Internal: packet dispatch (runs on the receive task thread) + // --------------------------------------------------------------- + + 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 { /* swallow callback errors */ } + } + 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 { /* swallow callback errors */ } + } + break; + } + + case PacketType.Error: + { + var err = ProtocolCodec.ParseServerError(packet); + _respTcs?.TrySetResult(err); + _cmdRespTcs?.TrySetResult(err); + break; + } + + case PacketType.Info: + // Silently discarded (welcome / goodbye messages). + break; + + case PacketType.Cmd: + // Server should never send CMD; ignore for forward compatibility. + break; + } + } + + internal void OnDisconnect() + { + UnblockWaiters(new RouterConnectionException("Connection lost unexpectedly.")); + } + } + + // ----------------------------------------------------------------------- + // Small convenience extensions + // ----------------------------------------------------------------------- + + 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..ba7dbad --- /dev/null +++ b/SDK/csharp/tests/CsmTcpRouter.Tests/ClientIntegrationTests.cs @@ -0,0 +1,364 @@ +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 +{ + /// + /// End-to-end client tests against a real loopback . + /// Mirrors SDK/python/tests/test_integration.py + portions of test_client.py. + /// + public class ClientIntegrationTests + { + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(2); + + /// + /// Bind a TcpListener to port 0 (OS-assigned), grab the port, then stop + /// the listener. The port is then almost certainly closed for the + /// duration of the test, so we can rely on connect attempts to fail + /// without depending on system state (e.g. port 1 may be open). + /// + private static int GetClosedPort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + // --------------------------------------------------------------- + // Connect / Disconnect + // --------------------------------------------------------------- + + [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 / built-ins + // --------------------------------------------------------------- + + [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); + // Server replies with CmdResp by default for unknown commands, not Resp, + // so a SendAndWait will time out waiting for a 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 (async cmd-resp handshake) + // --------------------------------------------------------------- + + [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); + // Make sure the server actually saw the command. + 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); + } + + // --------------------------------------------------------------- + // Subscribe / status broadcast + // --------------------------------------------------------------- + + [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); + } + + // --------------------------------------------------------------- + // Disconnect-while-waiting unblocks waiters + // --------------------------------------------------------------- + + [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); + + // Issue a SendAndWait whose response never comes; disconnect while waiting. + 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); + } + + // --------------------------------------------------------------- + // Async 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..7472167 --- /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 +{ + /// Minimal TCP server that emulates a CSM-TCP-Router for tests. + /// Mirrors the 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 { /* ignore */ } + } + } + + 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 { /* ignore */ } + } + } + + public string GetReceived(TimeSpan? timeout = null) + { + if (ReceivedCommands.TryTake(out var cmd, (int)(timeout ?? TimeSpan.FromSeconds(1)).TotalMilliseconds)) + return cmd; + return null; + } + + // ----------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------- + + 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 { /* ignore */ } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken ct) + { + try + { + var stream = client.GetStream(); + // Welcome info packet + 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 + { + // Generic async handshake for any other command. + 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..14d8e65 --- /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 +{ + // Mirrors 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() + { + // Manually craft a packet with an unknown type byte (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); + } + + // ------------------------------------------------------------------- + // Model parsing helpers + // ------------------------------------------------------------------- + + [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); + } + } +} From aa7d605572ca5acbd4be0a5e46601befffd77acf Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:30:06 +0800 Subject: [PATCH 07/10] Add C csm-tcp-router-client SDK (multi-platform, VS2026, CMake, tests) (#40) * Add C csm-tcp-router-client SDK with VS2026 + CMake + tests Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/ea6c42b2-b6f8-4ebe-8438-786601832ef5 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * Address PR review comments on the C SDK Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/de04612d-484c-4905-9c04-06c451a01e15 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * Drop redundant g_wsa_lock_inited; route cleanup through InitOnceExecuteOnce Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/de04612d-484c-4905-9c04-06c451a01e15 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --- .github/workflows/C_SDK.yml | 67 + SDK/c/.gitignore | 34 + SDK/c/CHANGELOG.md | 42 + SDK/c/CMakeLists.txt | 100 + SDK/c/LICENSE | 21 + SDK/c/README.md | 258 +++ SDK/c/README.zh-cn.md | 250 +++ SDK/c/examples/basic_usage.c | 61 + SDK/c/examples/subscribe_status.c | 65 + SDK/c/include/csm_tcp_router_client.h | 401 ++++ SDK/c/src/csm_tcp_router_client.c | 1631 +++++++++++++++++ SDK/c/tests/mock_server.c | 558 ++++++ SDK/c/tests/mock_server.h | 48 + SDK/c/tests/test_client.c | 51 + SDK/c/tests/test_harness.h | 62 + SDK/c/tests/test_integration.c | 165 ++ SDK/c/tests/test_main.c | 94 + SDK/c/tests/test_protocol.c | 84 + SDK/c/vs2026/README.md | 61 + SDK/c/vs2026/csm_tcp_router_client.sln | 43 + .../csm_tcp_router_client.tests.vcxproj | 94 + ...sm_tcp_router_client.tests.vcxproj.filters | 22 + .../csm_tcp_router_client.vcxproj | 87 + .../csm_tcp_router_client.vcxproj.filters | 23 + 24 files changed, 4322 insertions(+) create mode 100644 .github/workflows/C_SDK.yml create mode 100644 SDK/c/.gitignore create mode 100644 SDK/c/CHANGELOG.md create mode 100644 SDK/c/CMakeLists.txt create mode 100644 SDK/c/LICENSE create mode 100644 SDK/c/README.md create mode 100644 SDK/c/README.zh-cn.md create mode 100644 SDK/c/examples/basic_usage.c create mode 100644 SDK/c/examples/subscribe_status.c create mode 100644 SDK/c/include/csm_tcp_router_client.h create mode 100644 SDK/c/src/csm_tcp_router_client.c create mode 100644 SDK/c/tests/mock_server.c create mode 100644 SDK/c/tests/mock_server.h create mode 100644 SDK/c/tests/test_client.c create mode 100644 SDK/c/tests/test_harness.h create mode 100644 SDK/c/tests/test_integration.c create mode 100644 SDK/c/tests/test_main.c create mode 100644 SDK/c/tests/test_protocol.c create mode 100644 SDK/c/vs2026/README.md create mode 100644 SDK/c/vs2026/csm_tcp_router_client.sln create mode 100644 SDK/c/vs2026/csm_tcp_router_client.tests/csm_tcp_router_client.tests.vcxproj create mode 100644 SDK/c/vs2026/csm_tcp_router_client.tests/csm_tcp_router_client.tests.vcxproj.filters create mode 100644 SDK/c/vs2026/csm_tcp_router_client/csm_tcp_router_client.vcxproj create mode 100644 SDK/c/vs2026/csm_tcp_router_client/csm_tcp_router_client.vcxproj.filters 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/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..1c42a1f --- /dev/null +++ b/SDK/c/CMakeLists.txt @@ -0,0 +1,100 @@ +# 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) + target_link_libraries(basic_usage PRIVATE csm_tcp_router_client) + target_link_libraries(subscribe_status 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..c16fe98 --- /dev/null +++ b/SDK/c/examples/basic_usage.c @@ -0,0 +1,61 @@ +/* basic_usage.c - Demonstrates connecting, pinging, listing modules, and + * sending a synchronous command. Mirrors examples/basic_usage.py. */ +#include "csm_tcp_router_client.h" + +#include +#include + +#define HOST "localhost" +#define PORT 30007 + +int main(void) { + /* 1. Wait until the server is ready (optional). */ + 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. Create + connect. */ + 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. List CSM modules. */ + 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. Send a synchronous command (uncomment when wired to a real module). + * + * 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. Disconnect & clean up. */ + 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..9fd0df1 --- /dev/null +++ b/SDK/c/examples/subscribe_status.c @@ -0,0 +1,65 @@ +/* subscribe_status.c - Demonstrates real-time status subscription with a + * callback, mirroring 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..500cdac --- /dev/null +++ b/SDK/c/include/csm_tcp_router_client.h @@ -0,0 +1,401 @@ +/* csm_tcp_router_client.h - Single-header public C API for the CSM-TCP-Router + * client SDK. + * + * This SDK is the C counterpart of the Python `csm_tcp_router_client` module. + * It implements the CSM-TCP-Router protocol v0 over TCP and exposes a + * thread-safe synchronous client (`csm_client_t`) with both blocking calls + * and asynchronous callback / polling-queue delivery. + * + * Wire format (8-byte header, big-endian): + * + * | Data Length (4B) | Version (1B=0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | Payload | + * +---------------------------- Header (8B) ----------------------------+ + * + * Quickstart: + * + * 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); + * + * The library is portable across Windows (Winsock2 + Win32 threads) and + * POSIX systems (BSD sockets + pthreads), and is built as either a static + * or shared library by the bundled CMake / Visual Studio projects. + */ +#ifndef CSM_TCP_ROUTER_CLIENT_H +#define CSM_TCP_ROUTER_CLIENT_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------------- */ +/* Versioning and DLL export */ +/* ------------------------------------------------------------------------- */ + +#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 + +/* ------------------------------------------------------------------------- */ +/* Return codes */ +/* ------------------------------------------------------------------------- */ + +/** Result codes returned by every public SDK function. */ +typedef enum csm_result { + CSM_OK = 0, /**< Operation succeeded. */ + CSM_ERR_INVALID = -1, /**< Invalid argument or NULL pointer. */ + CSM_ERR_CONNECTION = -2, /**< Connection failed or was lost. */ + CSM_ERR_TIMEOUT = -3, /**< Operation exceeded its timeout. */ + CSM_ERR_PROTOCOL = -4, /**< Invalid / malformed protocol frame. */ + CSM_ERR_SERVER = -5, /**< Server returned an ERROR packet. */ + CSM_ERR_NOMEM = -6, /**< Memory allocation failure. */ + CSM_ERR_STATE = -7, /**< Operation invalid in current state. */ + CSM_ERR_IO = -8 /**< Underlying socket / OS I/O error. */ +} csm_result_t; + +/** Return a static, human-readable string for *code*. */ +CSM_API const char *csm_result_str(csm_result_t code); + +/* ------------------------------------------------------------------------- */ +/* Protocol constants */ +/* ------------------------------------------------------------------------- */ + +/** Packet type byte values (CSM-TCP-Router protocol v0). */ +typedef enum csm_packet_type { + CSM_PT_INFO = 0x00, /**< Welcome / goodbye informational text. */ + CSM_PT_ERROR = 0x01, /**< Server error: "[Error: ] " */ + CSM_PT_CMD = 0x02, /**< Command packet (client -> server). */ + CSM_PT_CMD_RESP = 0x03, /**< Async / subscribe handshake ACK. */ + CSM_PT_RESP = 0x04, /**< Synchronous response payload. */ + CSM_PT_ASYNC_RESP = 0x05, /**< Async response: " <- ". */ + CSM_PT_STATUS = 0x06, /**< Status broadcast. */ + CSM_PT_INTERRUPT = 0x07 /**< Interrupt broadcast. */ +} csm_packet_type_t; + +/** Number of bytes in the fixed wire-format header. */ +#define CSM_HEADER_SIZE 8 + +/** Protocol version byte sent in every outgoing packet. */ +#define CSM_PROTOCOL_VERSION 0x01 + +/* ------------------------------------------------------------------------- */ +/* Public data models */ +/* ------------------------------------------------------------------------- */ + +/** A decoded packet (header fields + heap-allocated body). */ +typedef struct csm_packet { + csm_packet_type_t type; + uint8_t version; + uint8_t flag1; + uint8_t flag2; + uint8_t *data; /**< Owned payload buffer (or NULL). */ + size_t data_len; /**< Length of `data` in bytes. */ +} csm_packet_t; + +/** A successful synchronous response. */ +typedef struct csm_command_response { + uint8_t *raw; /**< NUL-terminated UTF-8 payload (owned). */ + size_t raw_len; /**< Length of `raw` in bytes (excluding NUL). */ +} csm_command_response_t; + +/** An ASYNC_RESP packet: payload + the original command echoed by the server. + * + * Server format: ``" <- "``. */ +typedef struct csm_async_response { + char *raw; /**< Response payload (owned, NUL-terminated). */ + size_t raw_len; + char *original_command; /**< Echoed command text (owned). */ +} csm_async_response_t; + +/** A STATUS or INTERRUPT broadcast. + * + * Server format: ``" >> <- "``. */ +typedef struct csm_status_notification { + csm_packet_type_t packet_type; /**< CSM_PT_STATUS or CSM_PT_INTERRUPT */ + char *raw; /**< Full payload (owned). */ + size_t raw_len; + char *status_name; /**< Owned, NUL-terminated. */ + char *data; /**< Owned, NUL-terminated. */ + char *module_name; /**< Owned, NUL-terminated. */ +} csm_status_notification_t; + +/** Free a string previously returned via an out-parameter (e.g. by + * `csm_client_list_modules`). Safe to call with NULL. */ +CSM_API void csm_string_free(char *s); + +/** Free heap members of a `csm_command_response_t` (does not free the struct). */ +CSM_API void csm_command_response_dispose(csm_command_response_t *resp); + +/** Free heap members of a `csm_async_response_t` (does not free the struct). */ +CSM_API void csm_async_response_dispose(csm_async_response_t *resp); + +/** Free heap members of a `csm_status_notification_t` (does not free struct). */ +CSM_API void csm_status_notification_dispose(csm_status_notification_t *n); + +/** Free heap members of a `csm_packet_t` (does not free the struct). */ +CSM_API void csm_packet_dispose(csm_packet_t *pkt); + +/* ------------------------------------------------------------------------- */ +/* Server error info */ +/* ------------------------------------------------------------------------- */ + +/** Information about the most recent CSM_ERR_SERVER returned by a function. */ +typedef struct csm_server_error { + char code[32]; /**< NUL-terminated CSM error code (may be empty). */ + char message[256]; /**< NUL-terminated error message (truncated). */ +} csm_server_error_t; + +/* ------------------------------------------------------------------------- */ +/* Protocol codec (exposed for advanced use / testing) */ +/* ------------------------------------------------------------------------- */ + +/** Encode *data* (`data_len` bytes) into a complete wire-format packet. + * + * The caller must pass `out_buf` with at least `CSM_HEADER_SIZE + data_len` + * bytes. On success, `*out_len` is set to the number of bytes written. + */ +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); + +/** Decode an 8-byte header into its constituent fields. */ +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); + +/** Build a `csm_packet_t` from raw header + body. The returned packet + * **owns** a copy of the body; release it with `csm_packet_dispose`. + * + * Unknown packet type bytes are mapped to `CSM_PT_INFO` for forward + * compatibility (the server may introduce new types in future revisions). + */ +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); + +/* ------------------------------------------------------------------------- */ +/* Callback signatures */ +/* ------------------------------------------------------------------------- */ + +/** Status/interrupt notification callback. + * + * Invoked from the receive thread. Must be fast and non-blocking. The + * `notif` pointer and its members are valid only for the duration of the + * call; copy any data you need before returning. + */ +typedef void (*csm_status_callback_fn)(const csm_status_notification_t *notif, + void *user_data); + +/** Async response callback. Same threading rules as `csm_status_callback_fn`. */ +typedef void (*csm_async_callback_fn)(const csm_async_response_t *resp, + void *user_data); + +/* ------------------------------------------------------------------------- */ +/* Client lifecycle */ +/* ------------------------------------------------------------------------- */ + +/** Opaque thread-safe client handle. */ +typedef struct csm_client csm_client_t; + +/** Create a new client instance. Returns NULL on allocation failure. */ +CSM_API csm_client_t *csm_client_create(void); + +/** Disconnect (if connected) and free all resources held by *client*. */ +CSM_API void csm_client_destroy(csm_client_t *client); + +/** Open a TCP connection and start the background receive thread. + * + * @param connect_timeout_ms Connect timeout in milliseconds. + * @return CSM_OK or 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); + +/** Close the connection and stop the receive thread. Safe to call when not + * connected; any blocked callers receive CSM_ERR_CONNECTION immediately. */ +CSM_API csm_result_t csm_client_disconnect(csm_client_t *client); + +/** Return non-zero while the underlying socket is open. */ +CSM_API int csm_client_is_connected(const csm_client_t *client); + +/** Poll until *host*:*port* accepts a connection or *timeout_ms* elapses. + * + * @return CSM_OK when the server is reachable; CSM_ERR_TIMEOUT otherwise. */ +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); + +/* ------------------------------------------------------------------------- */ +/* Core command methods */ +/* ------------------------------------------------------------------------- */ + +/** Send a synchronous command and block until the response arrives. + * + * On CSM_OK the caller owns `*out_resp` and must release it via + * `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); + +/** Send an asynchronous command (`->` suffix) and block until the + * `cmd-resp` handshake arrives. The eventual `async-resp` is delivered to + * any callback registered via `csm_client_register_async_callback` and to + * the polling queue (`csm_client_poll_async_response`). */ +CSM_API csm_result_t csm_client_post(csm_client_t *client, + const char *command, + unsigned int timeout_ms); + +/** Send an async no-reply command (`->|` suffix) and block until the + * `cmd-resp` handshake arrives. */ +CSM_API csm_result_t csm_client_post_no_reply(csm_client_t *client, + const char *command, + unsigned int timeout_ms); + +/** Send a `Ping` and measure round-trip latency. + * + * @param out_elapsed_ms Set to round-trip time in milliseconds on success. + * @return CSM_OK or one of the regular error codes. + */ +CSM_API csm_result_t csm_client_ping(csm_client_t *client, + unsigned int timeout_ms, + double *out_elapsed_ms); + +/* ------------------------------------------------------------------------- */ +/* Router management helpers */ +/* ------------------------------------------------------------------------- */ + +/** Run `List` and return the response text. Caller frees `*out_text` via + * `csm_string_free`. */ +CSM_API csm_result_t csm_client_list_modules(csm_client_t *client, + char **out_text, + unsigned int timeout_ms); + +/** Run `List API `. Caller frees `*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); + +/** Run `List State `. Caller frees `*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); + +/** Run `Help `. Caller frees `*out_text`. */ +CSM_API csm_result_t csm_client_help(csm_client_t *client, + const char *module, + char **out_text, + unsigned int timeout_ms); + +/* ------------------------------------------------------------------------- */ +/* Status / interrupt subscriptions */ +/* ------------------------------------------------------------------------- */ + +/** Subscribe to a CSM module's status broadcast. + * + * Sends ``"@ ->"`` and blocks until the + * `cmd-resp` handshake arrives. `callback` (if non-NULL) is invoked from the + * receive thread for each notification; notifications are also enqueued for + * polling via `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); + +/** Cancel a status subscription. */ +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); + +/** Register a callback for `async-resp` packets matching *original_command*. */ +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); + +/** Remove a previously registered async callback. */ +CSM_API csm_result_t csm_client_unregister_async_callback(csm_client_t *client, + const char *original_command); + +/* ------------------------------------------------------------------------- */ +/* Polling queues (alternative to callbacks) */ +/* ------------------------------------------------------------------------- */ + +/** Pop the next status/interrupt notification from the polling queue. + * + * @param timeout_ms 0 = non-blocking; >0 = block up to N ms. + * @return CSM_OK with `*out_notif` populated (caller disposes via + * `csm_status_notification_dispose`); CSM_ERR_TIMEOUT if the queue + * is empty within the timeout; CSM_ERR_CONNECTION if disconnected. + */ +CSM_API csm_result_t csm_client_poll_status(csm_client_t *client, + csm_status_notification_t *out_notif, + unsigned int timeout_ms); + +/** Pop the next async response from the polling queue. */ +CSM_API csm_result_t csm_client_poll_async_response(csm_client_t *client, + csm_async_response_t *out_resp, + unsigned int timeout_ms); + +/* ------------------------------------------------------------------------- */ +/* Last server error */ +/* ------------------------------------------------------------------------- */ + +/** Retrieve information about the last CSM_ERR_SERVER observed by *client*. + * + * Returns CSM_OK and fills *out_err* with the most recently captured + * server-error code/message; otherwise (no server error has ever been + * observed for this client) returns CSM_ERR_STATE. The stored error is + * kept indefinitely until the next CSM_ERR_SERVER overwrites it, so it + * is safe to call this immediately after a failing operation without + * worrying about it being cleared by an unrelated success in between. + */ +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..f7c35d7 --- /dev/null +++ b/SDK/c/src/csm_tcp_router_client.c @@ -0,0 +1,1631 @@ +/* csm_tcp_router_client.c - Cross-platform implementation of the + * CSM-TCP-Router C client SDK. + * + * Threading: the receive loop runs on a single background thread. All + * public functions are safe to call from any thread; the client serialises + * concurrent waiters for synchronous (RESP) and command-handshake + * (CMD_RESP) responses respectively, mirroring the Python SDK. + * + * Sockets / threads abstraction: + * - Windows: Winsock2 + Win32 CRITICAL_SECTION / CONDITION_VARIABLE / threads. + * - POSIX: BSD sockets + 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 is defined by the build system (CMake / MSBuild) when + * compiling the library, so that csm_tcp_router_client.h decorates the + * exported symbols with the correct __declspec for shared builds. + * Defining it unconditionally here would break consumers that compile this + * .c file directly into their own DLL with a different export contract. */ + +/* ========================================================================= */ +/* Platform abstraction */ +/* ========================================================================= */ + +#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 /* reserved for future broadcast use */ +static void csm_cond_broadcast(csm_cond_t *c) { WakeAllConditionVariable(c); } +#endif + +/* Returns 1 on signal, 0 on timeout. */ +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 /* reserved for future broadcast use */ +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 bootstrap (Windows only) - reference-counted */ +/* ========================================================================= */ + +#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 guarantees the callback runs exactly once + * across all threads in the process, so the critical section is + * initialised exactly once even under concurrent client creation. */ + 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 + +/* ========================================================================= */ +/* Result code helpers */ +/* ========================================================================= */ + +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"; +} + +/* ========================================================================= */ +/* Memory helpers */ +/* ========================================================================= */ + +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; +} + +/* ========================================================================= */ +/* Protocol codec */ +/* ========================================================================= */ + +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; + + /* Forward-compatible: unknown type bytes are mapped to 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; +} + +/* ========================================================================= */ +/* Internal: server-error parsing */ +/* ========================================================================= */ + +/* Parse a packet payload of the form "[Error: ] " into 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'; + + /* Copy into a NUL-terminated stack buffer (capped). */ + 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'; + + /* Trim trailing whitespace. */ + 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)); + /* Trim leading/trailing spaces from code. */ + 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'; +} + +/* ========================================================================= */ +/* Internal: bounded queues */ +/* ========================================================================= */ + +/* Generic queue node. Items hold either a packet (for resp/cmd_resp), or + * a notification / async response (for the polling queues), or a sentinel + * (signaled via `is_disconnect`). */ +typedef struct csm_queue_node { + struct csm_queue_node *next; + void *item; /* type depends on queue */ + 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); +} + +/* Push an item; takes ownership of *item* on success. */ +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; +} + +/* Pop one item, blocking up to *timeout_ms*. Returns CSM_OK with *out_item + * set (and ownership transferred), CSM_ERR_TIMEOUT, CSM_ERR_CONNECTION + * (sentinel), or CSM_ERR_SERVER (with *out_err* populated). */ +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) { + /* Item not consumed (e.g. caller passed NULL out_item). Leak-safe + * default is to free as bytes via the disposer set by the caller's + * queue-specific wrapper; here we just drop it. */ + free(n->item); + } + free(n); + return result; +} + +/* ========================================================================= */ +/* Subscription / async-callback registries */ +/* ========================================================================= */ + +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; + +/* ========================================================================= */ +/* Client */ +/* ========================================================================= */ + +struct csm_client { + csm_socket_t sock; + csm_thread_t recv_thread; + int recv_thread_running; + int connected; /* set under state_lock */ + int stop_flag; /* set to request shutdown */ + + csm_mutex_t state_lock; /* protects connected/stop_flag/sock */ + csm_mutex_t send_lock; /* serialises sendall() */ + + csm_mutex_t resp_lock; /* at most one in-flight RESP waiter */ + csm_mutex_t cmd_resp_lock; /* at most one in-flight CMD_RESP waiter */ + + csm_queue_t resp_queue; /* items: csm_packet_t* */ + csm_queue_t cmd_resp_queue; /* items: csm_packet_t* (or NULL) */ + csm_queue_t status_queue; /* items: csm_status_notification_t* */ + csm_queue_t async_queue; /* items: csm_async_response_t* */ + + csm_mutex_t sub_lock; /* protects subscription registries */ + 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; +}; + +/* --- helpers --- */ + +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); +} + +/* Set client.sock under state_lock; closes any old one. */ +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; +} + +/* Remember the most-recent server error so callers can fetch it after + * receiving a 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); +} + +/* --- subscription registries (under 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); +} + +/* --- recv helpers --- */ + +/* Read exactly *size* bytes from sock; returns 0 on success, -1 on EOF/err. */ +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; +} + +/* --- Parsing helpers for ASYNC_RESP / STATUS payloads --- */ + +static void csm_async_resp_free_void(void *p); +static void csm_status_notif_free_void(void *p); + +/* Build an csm_async_response_t from raw payload data. */ +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; + /* Server format: " <- ". */ + 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; +} + +/* Build a csm_status_notification_t from raw payload data. */ +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; + + /* Find rightmost " <- " separator (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; + } + } + } + + /* Trim whitespace from module. */ + 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--; + } + + /* Split left by " >> " into 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; + } + } + } + + /* Trim status_name and 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; +} + +/* --- Receive thread --- */ + +static void csm_dispatch_packet(csm_client_t *c, csm_packet_t *pkt) { + /* On RESP / CMD_RESP / ERROR we transfer ownership of the packet + * (or err sentinel) into a queue. On STATUS / ASYNC_RESP / INTERRUPT + * we build a higher-level object and dispose of the raw packet. */ + 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; + /* Push to resp queue; queue-node owns it. */ + 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) { + /* Look up callback under 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); + /* Push a copy onto polling queue so callback users and + * polling users are independent. */ + 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); + /* Push a copy onto polling queue. */ + 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 is silently discarded; CMD never sent by server. */ + 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 (;;) { + /* Snapshot stop_flag and sock under state_lock. csm_client_disconnect() + * mutates both fields under the same lock, so a torn read or a stale + * sock value cannot occur and TSAN/UBSan stay quiet. */ + 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) { + /* Skip corrupt frame; keep loop alive. */ + continue; + } + /* Allocate heap copy to pass ownership to dispatch. */ + 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); + } + + /* Notify any blocked waiters that the connection is gone. */ + 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 +} + +/* --- Lifecycle --- */ + +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(); +} + +/* Resolve host and connect with a timeout. Returns CSM_OK or + * 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; + + /* Switch to non-blocking for connect-with-timeout. */ +#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) { + /* Switch back to blocking for the recv loop. */ +#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); + + /* Wake any blocked waiters before tearing down the socket. */ + 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; + /* Casting away const to take the lock; logically this is a read. */ + 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; +} + +/* --- Send helpers --- */ + +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; +} + +/* Pack and send a CMD packet. */ +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; +} + +/* --- Wait helpers --- */ + +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; + /* Discard handshake payload. */ + csm_packet_t *pkt = (csm_packet_t *)item; + csm_packet_free_void(pkt); + return CSM_OK; +} + +/* --- Public command 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; +} + +/* Shared helper: send a fixed-text command and return the response text. */ +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; /* transfer ownership; was NUL-terminated */ + 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); +} + +/* Build a " " command and send. */ +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); +} + +/* --- Subscriptions --- */ + +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; + + /* Register first to eliminate the race where a STATUS arrives before + * the callback is stored. */ + csm_result_t r = csm_register_status_sub(client, status_name, module_name, + callback, user_data); + if (r != CSM_OK) return r; + + /* Build "@ ->". */ + 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; +} + +/* --- Polling queues --- */ + +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; + /* Move ownership of fields from src to 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..9665e2d --- /dev/null +++ b/SDK/c/tests/mock_server.c @@ -0,0 +1,558 @@ +/* mock_server.c - cross-platform implementation of the test mock server. */ + +#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 /* reserved for future use */ +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 /* reserved for future use */ +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 + +#define MS_MAX_CLIENTS 16 +#define MS_HEADER 8 +#define MS_VER 0x01 + +/* --- response map --- */ +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; + +/* --- received command queue --- */ +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]; +} + +/* Encode header + payload into newly-allocated buffer; caller frees. */ +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; +} + +/* --- Public 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) { + /* Look up custom response. */ + 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); + + /* Built-in defaults. */ + 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 { + /* Generic async handshake. */ + 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); + + /* Send welcome 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'; + + /* Trim trailing whitespace. */ + 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'; + + /* Handle the command first (using the local copy). */ + ms_handle_command(s, conn, cmd); + + /* Then enqueue a copy for the test to inspect. */ + 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); + } + + /* Remove from clients list. */ + 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; + } + /* Close client sockets to wake handlers. */ + 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; + } + /* Wait for handlers to finish (best-effort, brief). */ + 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..1c8f611 --- /dev/null +++ b/SDK/c/tests/mock_server.h @@ -0,0 +1,48 @@ +/* mock_server.h - In-process TCP server emulating a CSM-TCP-Router for tests. + * + * Mirrors the Python `tests/conftest.py` MockServer fixture. + */ +#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; + +/** Create a stopped mock server bound to 127.0.0.1; the actual port is + * assigned by the OS in csm_mock_server_start(). */ +csm_mock_server_t *csm_mock_server_create(void); + +/** Free a (running or stopped) mock server. */ +void csm_mock_server_destroy(csm_mock_server_t *s); + +/** Bind to 127.0.0.1, an ephemeral port, and start the accept thread. */ +int csm_mock_server_start(csm_mock_server_t *s); + +/** Stop the accept thread and close all client connections. */ +void csm_mock_server_stop(csm_mock_server_t *s); + +/** Return the port the server is listening on (valid after start()). */ +uint16_t csm_mock_server_port(const csm_mock_server_t *s); + +/** Register a custom RESP reply for an exact command string. */ +void csm_mock_server_set_response(csm_mock_server_t *s, + const char *cmd_text, + const char *resp_text); + +/** Register an ERROR reply for an exact command string. */ +void csm_mock_server_set_error_response(csm_mock_server_t *s, + const char *cmd_text, + const char *error_text); + +/** Push a STATUS packet to all currently connected clients. */ +void csm_mock_server_push_status(csm_mock_server_t *s, const char *payload); + +/** Pop the next received command, blocking up to *timeout_ms*. The returned + * string is owned by the caller and must be freed with csm_string_free. + * Returns NULL on timeout. */ +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..279447d --- /dev/null +++ b/SDK/c/tests/test_client.c @@ -0,0 +1,51 @@ +/* test_client.c - Unit-level tests for the client object lifecycle that do + * not require a running mock server. */ +#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) { + /* Pick an arbitrary high port that should not be in use. */ + 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); + /* Either CSM_ERR_CONNECTION (refused) or CSM_ERR_TIMEOUT depending on OS. */ + 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..ad123c3 --- /dev/null +++ b/SDK/c/tests/test_harness.h @@ -0,0 +1,62 @@ +/* test_harness.h - Minimal in-process unit-test harness used by the + * csm-tcp-router-client C SDK tests. + * + * Tests register themselves via the CSM_TEST() macro; the runner in + * test_main.c picks them up via the link-time TESTS array, executes them, + * and prints a summary. Failures abort the current test only; assertions + * use longjmp to unwind back to the runner. + */ +#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; + +/* Provided by 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) + +/* Define a test function. The runner declares each test as extern via the + * CSM_TEST_EXTERN macro and registers it in its tests table. */ +#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..24f1a86 --- /dev/null +++ b/SDK/c/tests/test_integration.c @@ -0,0 +1,165 @@ +/* test_integration.c - End-to-end tests against the in-process 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 + +/* Helper: spin up server + connect a client. */ +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)++; + /* Sanity-check parsed fields. */ + (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); + /* Drain handshake from received queue. */ + char *cmd = csm_mock_server_get_received(s, 500); + csm_string_free(cmd); + /* Push a STATUS notification matching the subscription. */ + csm_mock_server_push_status(s, "Status >> 1.23 <- DAQmx"); + /* Wait for callback (poll up to 1s). */ + for (int i = 0; i < 100 && count == 0; ++i) it_sleep_ms(10); + CSM_ASSERT(count >= 1); + + /* Same notification should also be available via polling. */ + 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); + /* Send a command that has no canned response; mock returns CMD_RESP. */ + /* For this test simply disconnect immediately and verify subsequent send fails. */ + 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..4a6628b --- /dev/null +++ b/SDK/c/tests/test_main.c @@ -0,0 +1,94 @@ +/* test_main.c - Runner for the C SDK test suite. */ +#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` ensures these survive the longjmp performed by failing + * assertions inside individual test bodies. */ + 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..03f509d --- /dev/null +++ b/SDK/c/tests/test_protocol.c @@ -0,0 +1,84 @@ +/* test_protocol.c - Unit tests for the protocol codec. */ +#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); + + /* Header bytes: big-endian length, version, type, 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 + + + From 688ca013e66c5dac2516986e5b366adb06c7c86e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:45:00 +0800 Subject: [PATCH 08/10] Fix INADDR_LOOPBACK undeclared on macOS in mock_server.c (#41) Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/eab9769d-1260-4715-8d72-1e3241719ef1 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --- SDK/c/tests/mock_server.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/SDK/c/tests/mock_server.c b/SDK/c/tests/mock_server.c index 9665e2d..b16708d 100644 --- a/SDK/c/tests/mock_server.c +++ b/SDK/c/tests/mock_server.c @@ -79,6 +79,10 @@ static void ms_sleep_ms(unsigned int ms){ #endif #endif +#ifndef INADDR_LOOPBACK +# define INADDR_LOOPBACK 0x7F000001UL +#endif + #define MS_MAX_CLIENTS 16 #define MS_HEADER 8 #define MS_VER 0x01 From 6fc5eff34e9b3ac3b4c28d3b16b2ab197940aa8c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:35:30 +0800 Subject: [PATCH 09/10] =?UTF-8?q?=E5=B0=86=20SDK/=20=E4=B8=8B=E6=89=80?= =?UTF-8?q?=E6=9C=89=E4=BB=A3=E7=A0=81=E6=B3=A8=E9=87=8A=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E4=B8=BA=E4=B8=AD=E6=96=87=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * translate: convert all C SDK comments from English to Chinese Translated all code comments in 11 C SDK files from English to Chinese, preserving all code logic, variable names, function names, string literals, Doxygen tags (@param, @return, etc.) and technical proper names (TCP, CSM, WSA, POSIX, BSD, Win32, Winsock2, pthreads, CMake, NUL, DLL, etc.). Files translated: - SDK/c/include/csm_tcp_router_client.h - SDK/c/src/csm_tcp_router_client.c - SDK/c/examples/basic_usage.c - SDK/c/examples/subscribe_status.c - SDK/c/tests/test_harness.h - SDK/c/tests/mock_server.h - SDK/c/tests/mock_server.c - SDK/c/tests/test_main.c - SDK/c/tests/test_client.c - SDK/c/tests/test_protocol.c - SDK/c/tests/test_integration.c Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * translate: convert all C# SDK comments from English to Chinese Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * translate: convert all Python SDK comments from English to Chinese Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * translate: convert all Python SDK comments from English to Chinese (partial) Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/097602f2-f319-49ea-84a2-3c8a30aa0915 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --- SDK/c/examples/basic_usage.c | 16 +- SDK/c/examples/subscribe_status.c | 4 +- SDK/c/include/csm_tcp_router_client.h | 266 +++++++++--------- SDK/c/src/csm_tcp_router_client.c | 207 +++++++------- SDK/c/tests/mock_server.c | 34 +-- SDK/c/tests/mock_server.h | 28 +- SDK/c/tests/test_client.c | 8 +- SDK/c/tests/test_harness.h | 16 +- SDK/c/tests/test_integration.c | 18 +- SDK/c/tests/test_main.c | 5 +- SDK/c/tests/test_protocol.c | 4 +- SDK/csharp/examples/BasicUsage/Program.cs | 24 +- SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs | 246 ++++++++-------- .../ClientIntegrationTests.cs | 31 +- .../tests/CsmTcpRouter.Tests/MockServer.cs | 16 +- .../tests/CsmTcpRouter.Tests/ProtocolTests.cs | 6 +- SDK/python/src/csm_tcp_router_client.py | 257 ++++++++--------- 17 files changed, 577 insertions(+), 609 deletions(-) diff --git a/SDK/c/examples/basic_usage.c b/SDK/c/examples/basic_usage.c index c16fe98..bb9331f 100644 --- a/SDK/c/examples/basic_usage.c +++ b/SDK/c/examples/basic_usage.c @@ -1,5 +1,5 @@ -/* basic_usage.c - Demonstrates connecting, pinging, listing modules, and - * sending a synchronous command. Mirrors examples/basic_usage.py. */ +/* basic_usage.c - 演示连接、Ping、列出模块以及 + * 发送同步命令。对应 examples/basic_usage.py。 */ #include "csm_tcp_router_client.h" #include @@ -9,7 +9,7 @@ #define PORT 30007 int main(void) { - /* 1. Wait until the server is ready (optional). */ + /* 1. 等待服务器就绪(可选)。 */ printf("Waiting for server ... "); fflush(stdout); csm_result_t r = csm_client_wait_for_server(HOST, PORT, 30000, 500); @@ -19,7 +19,7 @@ int main(void) { } printf("ready.\n"); - /* 2. Create + connect. */ + /* 2. 创建 + 连接。 */ csm_client_t *c = csm_client_create(); if (!c) { fprintf(stderr, "Out of memory\n"); return 1; } @@ -31,20 +31,20 @@ int main(void) { } printf("Connected to %s:%d\n", HOST, PORT); - /* 3. Ping. */ + /* 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. List CSM modules. */ + /* 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. Send a synchronous command (uncomment when wired to a real module). + /* 5. 发送同步命令(连接到真实模块后取消注释)。 * * csm_command_response_t resp = {0}; * if (csm_client_send_and_wait(c, "API: Read -@ DAQmx", 5000, &resp) == CSM_OK) { @@ -53,7 +53,7 @@ int main(void) { * csm_command_response_dispose(&resp); */ - /* 6. Disconnect & clean up. */ + /* 6. 断开连接并清理资源。 */ csm_client_disconnect(c); csm_client_destroy(c); printf("Disconnected.\n"); diff --git a/SDK/c/examples/subscribe_status.c b/SDK/c/examples/subscribe_status.c index 9fd0df1..f4396bb 100644 --- a/SDK/c/examples/subscribe_status.c +++ b/SDK/c/examples/subscribe_status.c @@ -1,5 +1,5 @@ -/* subscribe_status.c - Demonstrates real-time status subscription with a - * callback, mirroring examples/subscribe_status.py. */ +/* subscribe_status.c - 演示使用回调进行实时状态订阅, + * 对应 examples/subscribe_status.py。 */ #if !defined(_WIN32) # ifndef _POSIX_C_SOURCE diff --git a/SDK/c/include/csm_tcp_router_client.h b/SDK/c/include/csm_tcp_router_client.h index 500cdac..378ad80 100644 --- a/SDK/c/include/csm_tcp_router_client.h +++ b/SDK/c/include/csm_tcp_router_client.h @@ -1,17 +1,16 @@ -/* csm_tcp_router_client.h - Single-header public C API for the CSM-TCP-Router - * client SDK. +/* csm_tcp_router_client.h - 适用于 CSM-TCP-Router 客户端 SDK 的单头文件公共 C API。 * - * This SDK is the C counterpart of the Python `csm_tcp_router_client` module. - * It implements the CSM-TCP-Router protocol v0 over TCP and exposes a - * thread-safe synchronous client (`csm_client_t`) with both blocking calls - * and asynchronous callback / polling-queue delivery. + * 本 SDK 是 Python `csm_tcp_router_client` 模块的 C 对应版本。 + * 它通过 TCP 实现 CSM-TCP-Router 协议 v0,并提供一个 + * 线程安全的同步客户端(`csm_client_t`),支持阻塞调用 + * 以及异步回调/轮询队列两种投递方式。 * - * Wire format (8-byte header, big-endian): + * 线帧格式(8 字节头部,大端序): * * | Data Length (4B) | Version (1B=0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | Payload | * +---------------------------- Header (8B) ----------------------------+ * - * Quickstart: + * 快速入门: * * csm_client_t *c = csm_client_create(); * if (csm_client_connect(c, "localhost", 30007, 5000) == CSM_OK) { @@ -24,9 +23,9 @@ * } * csm_client_destroy(c); * - * The library is portable across Windows (Winsock2 + Win32 threads) and - * POSIX systems (BSD sockets + pthreads), and is built as either a static - * or shared library by the bundled CMake / Visual Studio projects. + * 本库可跨平台运行于 Windows(Winsock2 + Win32 线程)和 + * POSIX 系统(BSD 套接字 + pthreads),通过附带的 CMake / Visual Studio 项目 + * 构建为静态库或共享库。 */ #ifndef CSM_TCP_ROUTER_CLIENT_H #define CSM_TCP_ROUTER_CLIENT_H @@ -39,7 +38,7 @@ extern "C" { #endif /* ------------------------------------------------------------------------- */ -/* Versioning and DLL export */ +/* 版本和 DLL 导出 */ /* ------------------------------------------------------------------------- */ #define CSM_VERSION_MAJOR 0 @@ -58,122 +57,122 @@ extern "C" { #endif /* ------------------------------------------------------------------------- */ -/* Return codes */ +/* 返回码 */ /* ------------------------------------------------------------------------- */ -/** Result codes returned by every public SDK function. */ +/** 所有公共 SDK 函数返回的结果码。 */ typedef enum csm_result { - CSM_OK = 0, /**< Operation succeeded. */ - CSM_ERR_INVALID = -1, /**< Invalid argument or NULL pointer. */ - CSM_ERR_CONNECTION = -2, /**< Connection failed or was lost. */ - CSM_ERR_TIMEOUT = -3, /**< Operation exceeded its timeout. */ - CSM_ERR_PROTOCOL = -4, /**< Invalid / malformed protocol frame. */ - CSM_ERR_SERVER = -5, /**< Server returned an ERROR packet. */ - CSM_ERR_NOMEM = -6, /**< Memory allocation failure. */ - CSM_ERR_STATE = -7, /**< Operation invalid in current state. */ - CSM_ERR_IO = -8 /**< Underlying socket / OS I/O error. */ + 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; -/** Return a static, human-readable string for *code*. */ +/** 返回 *code* 对应的静态可读字符串。 */ CSM_API const char *csm_result_str(csm_result_t code); /* ------------------------------------------------------------------------- */ -/* Protocol constants */ +/* 协议常量 */ /* ------------------------------------------------------------------------- */ -/** Packet type byte values (CSM-TCP-Router protocol v0). */ +/** 数据包类型字节值(CSM-TCP-Router 协议 v0)。 */ typedef enum csm_packet_type { - CSM_PT_INFO = 0x00, /**< Welcome / goodbye informational text. */ - CSM_PT_ERROR = 0x01, /**< Server error: "[Error: ] " */ - CSM_PT_CMD = 0x02, /**< Command packet (client -> server). */ - CSM_PT_CMD_RESP = 0x03, /**< Async / subscribe handshake ACK. */ - CSM_PT_RESP = 0x04, /**< Synchronous response payload. */ - CSM_PT_ASYNC_RESP = 0x05, /**< Async response: " <- ". */ - CSM_PT_STATUS = 0x06, /**< Status broadcast. */ - CSM_PT_INTERRUPT = 0x07 /**< Interrupt broadcast. */ + 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; -/** Number of bytes in the fixed wire-format header. */ +/** 固定线帧格式头部的字节数。 */ #define CSM_HEADER_SIZE 8 -/** Protocol version byte sent in every outgoing packet. */ +/** 每个发出数据包中发送的协议版本字节。 */ #define CSM_PROTOCOL_VERSION 0x01 /* ------------------------------------------------------------------------- */ -/* Public data models */ +/* 公共数据模型 */ /* ------------------------------------------------------------------------- */ -/** A decoded packet (header fields + heap-allocated body). */ +/** 已解码的数据包(头部字段 + 堆分配的主体)。 */ typedef struct csm_packet { csm_packet_type_t type; uint8_t version; uint8_t flag1; uint8_t flag2; - uint8_t *data; /**< Owned payload buffer (or NULL). */ - size_t data_len; /**< Length of `data` in bytes. */ + uint8_t *data; /**< 拥有的载荷缓冲区(或 NULL)。 */ + size_t data_len; /**< `data` 的字节长度。 */ } csm_packet_t; -/** A successful synchronous response. */ +/** 成功的同步响应。 */ typedef struct csm_command_response { - uint8_t *raw; /**< NUL-terminated UTF-8 payload (owned). */ - size_t raw_len; /**< Length of `raw` in bytes (excluding NUL). */ + uint8_t *raw; /**< NUL 终止的 UTF-8 载荷(已拥有)。 */ + size_t raw_len; /**< `raw` 的字节长度(不含 NUL)。 */ } csm_command_response_t; -/** An ASYNC_RESP packet: payload + the original command echoed by the server. +/** ASYNC_RESP 数据包:载荷 + 服务器回显的原始命令。 * - * Server format: ``" <- "``. */ + * 服务器格式:``" <- "``。 */ typedef struct csm_async_response { - char *raw; /**< Response payload (owned, NUL-terminated). */ + char *raw; /**< 响应载荷(已拥有,NUL 终止)。 */ size_t raw_len; - char *original_command; /**< Echoed command text (owned). */ + char *original_command; /**< 回显的命令文本(已拥有)。 */ } csm_async_response_t; -/** A STATUS or INTERRUPT broadcast. +/** STATUS 或 INTERRUPT 广播。 * - * Server format: ``" >> <- "``. */ + * 服务器格式:``" >> <- "``。 */ typedef struct csm_status_notification { - csm_packet_type_t packet_type; /**< CSM_PT_STATUS or CSM_PT_INTERRUPT */ - char *raw; /**< Full payload (owned). */ + csm_packet_type_t packet_type; /**< CSM_PT_STATUS 或 CSM_PT_INTERRUPT */ + char *raw; /**< 完整载荷(已拥有)。 */ size_t raw_len; - char *status_name; /**< Owned, NUL-terminated. */ - char *data; /**< Owned, NUL-terminated. */ - char *module_name; /**< Owned, NUL-terminated. */ + char *status_name; /**< 已拥有,NUL 终止。 */ + char *data; /**< 已拥有,NUL 终止。 */ + char *module_name; /**< 已拥有,NUL 终止。 */ } csm_status_notification_t; -/** Free a string previously returned via an out-parameter (e.g. by - * `csm_client_list_modules`). Safe to call with NULL. */ +/** 释放通过出参(例如由 `csm_client_list_modules` 返回)分配的字符串。 + * 传入 NULL 时安全。 */ CSM_API void csm_string_free(char *s); -/** Free heap members of a `csm_command_response_t` (does not free the struct). */ +/** 释放 `csm_command_response_t` 的堆成员(不释放结构体本身)。 */ CSM_API void csm_command_response_dispose(csm_command_response_t *resp); -/** Free heap members of a `csm_async_response_t` (does not free the struct). */ +/** 释放 `csm_async_response_t` 的堆成员(不释放结构体本身)。 */ CSM_API void csm_async_response_dispose(csm_async_response_t *resp); -/** Free heap members of a `csm_status_notification_t` (does not free struct). */ +/** 释放 `csm_status_notification_t` 的堆成员(不释放结构体本身)。 */ CSM_API void csm_status_notification_dispose(csm_status_notification_t *n); -/** Free heap members of a `csm_packet_t` (does not free the struct). */ +/** 释放 `csm_packet_t` 的堆成员(不释放结构体本身)。 */ CSM_API void csm_packet_dispose(csm_packet_t *pkt); /* ------------------------------------------------------------------------- */ -/* Server error info */ +/* 服务器错误信息 */ /* ------------------------------------------------------------------------- */ -/** Information about the most recent CSM_ERR_SERVER returned by a function. */ +/** 函数最近返回 CSM_ERR_SERVER 时的相关信息。 */ typedef struct csm_server_error { - char code[32]; /**< NUL-terminated CSM error code (may be empty). */ - char message[256]; /**< NUL-terminated error message (truncated). */ + char code[32]; /**< NUL 终止的 CSM 错误码(可为空)。 */ + char message[256]; /**< NUL 终止的错误消息(已截断)。 */ } csm_server_error_t; /* ------------------------------------------------------------------------- */ -/* Protocol codec (exposed for advanced use / testing) */ +/* 协议编解码(暴露用于高级用途/测试) */ /* ------------------------------------------------------------------------- */ -/** Encode *data* (`data_len` bytes) into a complete wire-format packet. +/** 将 *data*(`data_len` 字节)编码为完整的线帧格式数据包。 * - * The caller must pass `out_buf` with at least `CSM_HEADER_SIZE + data_len` - * bytes. On success, `*out_len` is set to the number of bytes written. + * 调用者必须传入至少 `CSM_HEADER_SIZE + data_len` 字节的 `out_buf`。 + * 成功时 `*out_len` 将被设置为写入的字节数。 */ CSM_API csm_result_t csm_encode_packet(const void *data, size_t data_len, @@ -184,7 +183,7 @@ CSM_API csm_result_t csm_encode_packet(const void *data, size_t out_buf_size, size_t *out_len); -/** Decode an 8-byte header into its constituent fields. */ +/** 将 8 字节头部解码为各组成字段。 */ CSM_API csm_result_t csm_decode_header(const uint8_t *header_bytes, size_t header_len, uint32_t *out_data_len, @@ -193,11 +192,11 @@ CSM_API csm_result_t csm_decode_header(const uint8_t *header_bytes, uint8_t *out_flag1, uint8_t *out_flag2); -/** Build a `csm_packet_t` from raw header + body. The returned packet - * **owns** a copy of the body; release it with `csm_packet_dispose`. +/** 从原始头部 + 主体构建 `csm_packet_t`。返回的数据包 + * **拥有** 主体的副本;使用 `csm_packet_dispose` 释放。 * - * Unknown packet type bytes are mapped to `CSM_PT_INFO` for forward - * compatibility (the server may introduce new types in future revisions). + * 未知数据包类型字节将被映射到 `CSM_PT_INFO` 以实现前向 + * 兼容性(服务器可能在未来版本中引入新类型)。 */ CSM_API csm_result_t csm_parse_packet(const uint8_t *header_bytes, size_t header_len, @@ -206,134 +205,134 @@ CSM_API csm_result_t csm_parse_packet(const uint8_t *header_bytes, csm_packet_t *out_packet); /* ------------------------------------------------------------------------- */ -/* Callback signatures */ +/* 回调签名 */ /* ------------------------------------------------------------------------- */ -/** Status/interrupt notification callback. +/** 状态/中断通知回调。 * - * Invoked from the receive thread. Must be fast and non-blocking. The - * `notif` pointer and its members are valid only for the duration of the - * call; copy any data you need before returning. + * 从接收线程调用。必须快速且非阻塞。`notif` + * 指针及其成员仅在调用期间有效; + * 请在返回前复制所需数据。 */ typedef void (*csm_status_callback_fn)(const csm_status_notification_t *notif, void *user_data); -/** Async response callback. Same threading rules as `csm_status_callback_fn`. */ +/** 异步响应回调。与 `csm_status_callback_fn` 使用相同的线程规则。 */ typedef void (*csm_async_callback_fn)(const csm_async_response_t *resp, void *user_data); /* ------------------------------------------------------------------------- */ -/* Client lifecycle */ +/* 客户端生命周期 */ /* ------------------------------------------------------------------------- */ -/** Opaque thread-safe client handle. */ +/** 不透明的线程安全客户端句柄。 */ typedef struct csm_client csm_client_t; -/** Create a new client instance. Returns NULL on allocation failure. */ +/** 创建新的客户端实例。分配失败时返回 NULL。 */ CSM_API csm_client_t *csm_client_create(void); -/** Disconnect (if connected) and free all resources held by *client*. */ +/** 断开连接(如已连接)并释放 *client* 持有的所有资源。 */ CSM_API void csm_client_destroy(csm_client_t *client); -/** Open a TCP connection and start the background receive thread. +/** 打开 TCP 连接并启动后台接收线程。 * - * @param connect_timeout_ms Connect timeout in milliseconds. - * @return CSM_OK or CSM_ERR_CONNECTION / CSM_ERR_STATE / CSM_ERR_INVALID. + * @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); -/** Close the connection and stop the receive thread. Safe to call when not - * connected; any blocked callers receive CSM_ERR_CONNECTION immediately. */ +/** 关闭连接并停止接收线程。未连接时调用安全; + * 任何被阻塞的调用者将立即收到 CSM_ERR_CONNECTION。 */ CSM_API csm_result_t csm_client_disconnect(csm_client_t *client); -/** Return non-zero while the underlying socket is open. */ +/** 底层套接字打开时返回非零值。 */ CSM_API int csm_client_is_connected(const csm_client_t *client); -/** Poll until *host*:*port* accepts a connection or *timeout_ms* elapses. +/** 轮询直到 *host*:*port* 接受连接或 *timeout_ms* 超时。 * - * @return CSM_OK when the server is reachable; CSM_ERR_TIMEOUT otherwise. */ + * @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); /* ------------------------------------------------------------------------- */ -/* Core command methods */ +/* 核心命令方法 */ /* ------------------------------------------------------------------------- */ -/** Send a synchronous command and block until the response arrives. +/** 发送同步命令并阻塞直到响应到达。 * - * On CSM_OK the caller owns `*out_resp` and must release it via - * `csm_command_response_dispose`. */ + * 返回 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); -/** Send an asynchronous command (`->` suffix) and block until the - * `cmd-resp` handshake arrives. The eventual `async-resp` is delivered to - * any callback registered via `csm_client_register_async_callback` and to - * the polling queue (`csm_client_poll_async_response`). */ +/** 发送异步命令(`->` 后缀)并阻塞直到 + * `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); -/** Send an async no-reply command (`->|` suffix) and block until the - * `cmd-resp` handshake arrives. */ +/** 发送异步无回复命令(`->|` 后缀)并阻塞直到 + * `cmd-resp` 握手到达。 */ CSM_API csm_result_t csm_client_post_no_reply(csm_client_t *client, const char *command, unsigned int timeout_ms); -/** Send a `Ping` and measure round-trip latency. +/** 发送 `Ping` 并测量往返延迟。 * - * @param out_elapsed_ms Set to round-trip time in milliseconds on success. - * @return CSM_OK or one of the regular error codes. + * @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); /* ------------------------------------------------------------------------- */ -/* Router management helpers */ +/* 路由器管理辅助函数 */ /* ------------------------------------------------------------------------- */ -/** Run `List` and return the response text. Caller frees `*out_text` via - * `csm_string_free`. */ +/** 执行 `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); -/** Run `List API `. Caller frees `*out_text`. */ +/** 执行 `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); -/** Run `List State `. Caller frees `*out_text`. */ +/** 执行 `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); -/** Run `Help `. Caller frees `*out_text`. */ +/** 执行 `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); /* ------------------------------------------------------------------------- */ -/* Status / interrupt subscriptions */ +/* 状态/中断订阅 */ /* ------------------------------------------------------------------------- */ -/** Subscribe to a CSM module's status broadcast. +/** 订阅 CSM 模块的状态广播。 * - * Sends ``"@ ->"`` and blocks until the - * `cmd-resp` handshake arrives. `callback` (if non-NULL) is invoked from the - * receive thread for each notification; notifications are also enqueued for - * polling via `csm_client_poll_status`. + * 发送 ``"@ ->"`` 并阻塞直到 + * `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, @@ -342,54 +341,53 @@ CSM_API csm_result_t csm_client_subscribe_status(csm_client_t *client, void *user_data, unsigned int timeout_ms); -/** Cancel a status subscription. */ +/** 取消状态订阅。 */ 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); -/** Register a callback for `async-resp` packets matching *original_command*. */ +/** 为匹配 *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); -/** Remove a previously registered async callback. */ +/** 移除之前注册的异步回调。 */ CSM_API csm_result_t csm_client_unregister_async_callback(csm_client_t *client, const char *original_command); /* ------------------------------------------------------------------------- */ -/* Polling queues (alternative to callbacks) */ +/* 轮询队列(回调的替代方案) */ /* ------------------------------------------------------------------------- */ -/** Pop the next status/interrupt notification from the polling queue. +/** 从轮询队列弹出下一个状态/中断通知。 * - * @param timeout_ms 0 = non-blocking; >0 = block up to N ms. - * @return CSM_OK with `*out_notif` populated (caller disposes via - * `csm_status_notification_dispose`); CSM_ERR_TIMEOUT if the queue - * is empty within the timeout; CSM_ERR_CONNECTION if disconnected. + * @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); -/** Pop the next async response from the polling queue. */ +/** 从轮询队列弹出下一个异步响应。 */ CSM_API csm_result_t csm_client_poll_async_response(csm_client_t *client, csm_async_response_t *out_resp, unsigned int timeout_ms); /* ------------------------------------------------------------------------- */ -/* Last server error */ +/* 最后一次服务器错误 */ /* ------------------------------------------------------------------------- */ -/** Retrieve information about the last CSM_ERR_SERVER observed by *client*. +/** 获取 *client* 观察到的最后一次 CSM_ERR_SERVER 的信息。 * - * Returns CSM_OK and fills *out_err* with the most recently captured - * server-error code/message; otherwise (no server error has ever been - * observed for this client) returns CSM_ERR_STATE. The stored error is - * kept indefinitely until the next CSM_ERR_SERVER overwrites it, so it - * is safe to call this immediately after a failing operation without - * worrying about it being cleared by an unrelated success in between. + * 返回 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); diff --git a/SDK/c/src/csm_tcp_router_client.c b/SDK/c/src/csm_tcp_router_client.c index f7c35d7..7c3849a 100644 --- a/SDK/c/src/csm_tcp_router_client.c +++ b/SDK/c/src/csm_tcp_router_client.c @@ -1,14 +1,12 @@ -/* csm_tcp_router_client.c - Cross-platform implementation of the - * CSM-TCP-Router C client SDK. +/* csm_tcp_router_client.c - CSM-TCP-Router C 客户端 SDK 的跨平台实现。 * - * Threading: the receive loop runs on a single background thread. All - * public functions are safe to call from any thread; the client serialises - * concurrent waiters for synchronous (RESP) and command-handshake - * (CMD_RESP) responses respectively, mirroring the Python SDK. + * 线程模型:接收循环运行于单个后台线程。所有公共函数均可从任意线程安全调用; + * 客户端分别对同步(RESP)和命令握手(CMD_RESP)响应的并发等待者进行串行化, + * 与 Python SDK 保持一致。 * - * Sockets / threads abstraction: - * - Windows: Winsock2 + Win32 CRITICAL_SECTION / CONDITION_VARIABLE / threads. - * - POSIX: BSD sockets + pthreads. + * 套接字 / 线程抽象: + * - Windows:Winsock2 + Win32 CRITICAL_SECTION / CONDITION_VARIABLE / 线程。 + * - POSIX: BSD 套接字 + pthreads。 */ #if !defined(_WIN32) @@ -30,14 +28,13 @@ #include #include -/* CSM_BUILD_LIBRARY is defined by the build system (CMake / MSBuild) when - * compiling the library, so that csm_tcp_router_client.h decorates the - * exported symbols with the correct __declspec for shared builds. - * Defining it unconditionally here would break consumers that compile this - * .c file directly into their own DLL with a different export contract. */ +/* CSM_BUILD_LIBRARY 由构建系统(CMake / MSBuild)在编译库时定义, + * 以便 csm_tcp_router_client.h 使用正确的 __declspec 装饰共享构建中 + * 导出的符号。在此处无条件定义会破坏那些将此 .c 文件直接编译进 + * 自己的 DLL(采用不同导出约定)的使用者。 */ /* ========================================================================= */ -/* Platform abstraction */ +/* 平台抽象 */ /* ========================================================================= */ #if defined(_WIN32) @@ -64,11 +61,11 @@ 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 /* reserved for future broadcast use */ +#if 0 /* 保留供将来广播使用 */ static void csm_cond_broadcast(csm_cond_t *c) { WakeAllConditionVariable(c); } #endif -/* Returns 1 on signal, 0 on timeout. */ +/* 有信号时返回 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; @@ -113,7 +110,7 @@ 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 /* reserved for future broadcast use */ +#if 0 /* 保留供将来广播使用 */ static void csm_cond_broadcast(csm_cond_t *c) { pthread_cond_broadcast(c); } #endif @@ -161,7 +158,7 @@ static double csm_monotonic_ms(void) { #endif /* ========================================================================= */ -/* WSA bootstrap (Windows only) - reference-counted */ +/* WSA 引导(仅 Windows)— 引用计数 */ /* ========================================================================= */ #if defined(_WIN32) @@ -178,9 +175,8 @@ static BOOL CALLBACK csm_wsa_lock_init_once_cb(PINIT_ONCE init_once, } static void csm_wsa_lock_init_once(void) { - /* InitOnceExecuteOnce guarantees the callback runs exactly once - * across all threads in the process, so the critical section is - * initialised exactly once even under concurrent client creation. */ + /* InitOnceExecuteOnce 保证回调在进程内所有线程中恰好执行一次, + * 因此即使在并发创建客户端时,临界区也只会被初始化一次。 */ InitOnceExecuteOnce(&g_wsa_lock_init_once_state, csm_wsa_lock_init_once_cb, NULL, NULL); } @@ -215,7 +211,7 @@ static void csm_wsa_cleanup(void) {} #endif /* ========================================================================= */ -/* Result code helpers */ +/* 结果码辅助函数 */ /* ========================================================================= */ const char *csm_result_str(csm_result_t code) { @@ -234,7 +230,7 @@ const char *csm_result_str(csm_result_t code) { } /* ========================================================================= */ -/* Memory helpers */ +/* 内存辅助函数 */ /* ========================================================================= */ static char *csm_strdup_n(const char *s, size_t n) { @@ -288,7 +284,7 @@ void csm_packet_dispose(csm_packet_t *pkt) { } /* ========================================================================= */ -/* Protocol codec */ +/* 协议编解码 */ /* ========================================================================= */ static void csm_pack_be32(uint8_t *buf, uint32_t v) { @@ -355,7 +351,7 @@ csm_result_t csm_parse_packet(const uint8_t *header_bytes, if (r != CSM_OK) return r; if ((size_t)data_len != body_len) return CSM_ERR_PROTOCOL; - /* Forward-compatible: unknown type bytes are mapped to INFO. */ + /* 向前兼容:未知类型字节映射为 INFO。 */ csm_packet_type_t pt; switch (type_byte) { case CSM_PT_INFO: @@ -388,23 +384,23 @@ csm_result_t csm_parse_packet(const uint8_t *header_bytes, } /* ========================================================================= */ -/* Internal: server-error parsing */ +/* 内部:服务器错误解析 */ /* ========================================================================= */ -/* Parse a packet payload of the form "[Error: ] " into out_err. */ +/* 将形如 "[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'; - /* Copy into a NUL-terminated stack buffer (capped). */ + /* 复制到以 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'; - /* Trim trailing whitespace. */ + /* 去除尾部空白字符。 */ while (copy_len > 0 && (buf[copy_len - 1] == ' ' || buf[copy_len - 1] == '\r' || buf[copy_len - 1] == '\n' || @@ -419,7 +415,7 @@ static void csm_parse_server_error(const uint8_t *data, char *end = strchr(buf, ']'); if (end) { size_t code_len = (size_t)(end - (buf + prefix_len)); - /* Trim leading/trailing spaces from code. */ + /* 去除错误码首尾的空格。 */ const char *cs = buf + prefix_len; while (code_len && *cs == ' ') { cs++; code_len--; } while (code_len && cs[code_len - 1] == ' ') code_len--; @@ -440,15 +436,14 @@ static void csm_parse_server_error(const uint8_t *data, } /* ========================================================================= */ -/* Internal: bounded queues */ +/* 内部:有界队列 */ /* ========================================================================= */ -/* Generic queue node. Items hold either a packet (for resp/cmd_resp), or - * a notification / async response (for the polling queues), or a sentinel - * (signaled via `is_disconnect`). */ +/* 通用队列节点。元素持有数据包(用于 resp/cmd_resp)、 + * 通知 / 异步响应(用于轮询队列),或哨兵(通过 `is_disconnect` 发出信号)。 */ typedef struct csm_queue_node { struct csm_queue_node *next; - void *item; /* type depends on queue */ + void *item; /* 类型取决于所属队列 */ int is_disconnect; int is_server_error; csm_server_error_t server_error; @@ -481,7 +476,7 @@ static void csm_queue_destroy_with(csm_queue_t *q, csm_mutex_destroy(&q->lock); } -/* Push an item; takes ownership of *item* on success. */ +/* 压入一个元素;成功时获得 *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) { @@ -501,9 +496,9 @@ static int csm_queue_push(csm_queue_t *q, void *item, return 0; } -/* Pop one item, blocking up to *timeout_ms*. Returns CSM_OK with *out_item - * set (and ownership transferred), CSM_ERR_TIMEOUT, CSM_ERR_CONNECTION - * (sentinel), or CSM_ERR_SERVER (with *out_err* populated). */ +/* 弹出一个元素,最多阻塞 *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, @@ -537,9 +532,9 @@ static csm_result_t csm_queue_pop(csm_queue_t *q, n->item = NULL; } if (n->item) { - /* Item not consumed (e.g. caller passed NULL out_item). Leak-safe - * default is to free as bytes via the disposer set by the caller's - * queue-specific wrapper; here we just drop it. */ + /* 元素未被消费(例如调用者传入了 NULL out_item)。 + * 默认以字节方式通过调用者队列特定包装器设置的释放函数释放, + * 此处直接丢弃以保证无内存泄漏。 */ free(n->item); } free(n); @@ -547,7 +542,7 @@ static csm_result_t csm_queue_pop(csm_queue_t *q, } /* ========================================================================= */ -/* Subscription / async-callback registries */ +/* 订阅 / 异步回调注册表 */ /* ========================================================================= */ typedef struct csm_status_sub { @@ -566,28 +561,28 @@ typedef struct csm_async_sub { } csm_async_sub_t; /* ========================================================================= */ -/* Client */ +/* 客户端 */ /* ========================================================================= */ struct csm_client { csm_socket_t sock; csm_thread_t recv_thread; int recv_thread_running; - int connected; /* set under state_lock */ - int stop_flag; /* set to request shutdown */ + int connected; /* 在 state_lock 下设置 */ + int stop_flag; /* 置位以请求关闭 */ - csm_mutex_t state_lock; /* protects connected/stop_flag/sock */ - csm_mutex_t send_lock; /* serialises sendall() */ + csm_mutex_t state_lock; /* 保护 connected/stop_flag/sock */ + csm_mutex_t send_lock; /* 串行化 sendall() */ - csm_mutex_t resp_lock; /* at most one in-flight RESP waiter */ - csm_mutex_t cmd_resp_lock; /* at most one in-flight CMD_RESP waiter */ + csm_mutex_t resp_lock; /* 最多一个在途 RESP 等待者 */ + csm_mutex_t cmd_resp_lock; /* 最多一个在途 CMD_RESP 等待者 */ - csm_queue_t resp_queue; /* items: csm_packet_t* */ - csm_queue_t cmd_resp_queue; /* items: csm_packet_t* (or NULL) */ - csm_queue_t status_queue; /* items: csm_status_notification_t* */ - csm_queue_t async_queue; /* items: csm_async_response_t* */ + 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; /* protects subscription registries */ + csm_mutex_t sub_lock; /* 保护订阅注册表 */ csm_status_sub_t *status_subs; csm_async_sub_t *async_subs; @@ -596,7 +591,7 @@ struct csm_client { csm_server_error_t last_server_error; }; -/* --- helpers --- */ +/* --- 辅助函数 --- */ static void csm_packet_free_void(void *p) { csm_packet_t *pkt = (csm_packet_t *)p; @@ -619,14 +614,13 @@ static void csm_async_resp_free_void(void *p) { free(r); } -/* Set client.sock under state_lock; closes any old one. */ +/* 在 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; } -/* Remember the most-recent server error so callers can fetch it after - * receiving a CSM_ERR_SERVER. */ +/* 记录最近一次服务器错误,以便调用者在收到 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); @@ -635,7 +629,7 @@ static void csm_record_server_error(csm_client_t *c, csm_mutex_unlock(&c->err_lock); } -/* --- subscription registries (under sub_lock) --- */ +/* --- 订阅注册表(在 sub_lock 下操作)--- */ static csm_status_sub_t *csm_find_status_sub(csm_client_t *c, const char *status_name, @@ -755,9 +749,9 @@ static void csm_free_all_subs(csm_client_t *c) { csm_mutex_unlock(&c->sub_lock); } -/* --- recv helpers --- */ +/* --- 接收辅助函数 --- */ -/* Read exactly *size* bytes from sock; returns 0 on success, -1 on EOF/err. */ +/* 从套接字精确读取 *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) { @@ -772,17 +766,17 @@ static int csm_recv_all(csm_socket_t sock, uint8_t *buf, size_t size) { return 0; } -/* --- Parsing helpers for ASYNC_RESP / STATUS payloads --- */ +/* --- ASYNC_RESP / STATUS 载荷的解析辅助函数 --- */ static void csm_async_resp_free_void(void *p); static void csm_status_notif_free_void(void *p); -/* Build an csm_async_response_t from raw payload data. */ +/* 从原始载荷数据构造 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; - /* Server format: " <- ". */ + /* 服务器格式:" <- "。 */ const char *sep = " <- "; const size_t seplen = 4; size_t split = (size_t)-1; @@ -808,7 +802,7 @@ static csm_async_response_t *csm_make_async_response(const uint8_t *data, return r; } -/* Build a csm_status_notification_t from raw payload data. */ +/* 从原始载荷数据构造 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) { @@ -821,7 +815,7 @@ static csm_status_notification_t *csm_make_status_notif(csm_packet_type_t pt, n->raw[len] = '\0'; n->raw_len = len; - /* Find rightmost " <- " separator (rsplit by 1). */ + /* 从右向左查找最后一个 " <- " 分隔符(rsplit by 1)。 */ const char *raw_str = n->raw; const char *left = raw_str; size_t left_len = len; @@ -838,7 +832,7 @@ static csm_status_notification_t *csm_make_status_notif(csm_packet_type_t pt, } } - /* Trim whitespace from module. */ + /* 去除模块名首尾空白字符。 */ while (module_len && (*module_start == ' ' || *module_start == '\t')) { module_start++; module_len--; } @@ -849,7 +843,7 @@ static csm_status_notification_t *csm_make_status_notif(csm_packet_type_t pt, module_len--; } - /* Split left by " >> " into status_name + data. */ + /* 以 " >> " 将左半部分拆分为 status_name 和 data。 */ const char *status_start = NULL; size_t status_len = 0; const char *data_start = left; @@ -866,7 +860,7 @@ static csm_status_notification_t *csm_make_status_notif(csm_packet_type_t pt, } } - /* Trim status_name and data. */ + /* 去除 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--; } @@ -885,18 +879,17 @@ static csm_status_notification_t *csm_make_status_notif(csm_packet_type_t pt, return n; } -/* --- Receive thread --- */ +/* --- 接收线程 --- */ static void csm_dispatch_packet(csm_client_t *c, csm_packet_t *pkt) { - /* On RESP / CMD_RESP / ERROR we transfer ownership of the packet - * (or err sentinel) into a queue. On STATUS / ASYNC_RESP / INTERRUPT - * we build a higher-level object and dispose of the raw packet. */ + /* 收到 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; - /* Push to resp queue; queue-node owns it. */ + /* 压入 resp 队列;队列节点持有所有权。 */ if (csm_queue_push(&c->resp_queue, heap, 0, 0, NULL) != 0) { csm_packet_free_void(heap); } @@ -925,7 +918,7 @@ static void csm_dispatch_packet(csm_client_t *c, csm_packet_t *pkt) { case CSM_PT_ASYNC_RESP: { csm_async_response_t *r = csm_make_async_response(pkt->data, pkt->data_len); if (r) { - /* Look up callback under sub_lock. */ + /* 在 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; @@ -937,8 +930,7 @@ static void csm_dispatch_packet(csm_client_t *c, csm_packet_t *pkt) { } csm_mutex_unlock(&c->sub_lock); if (cb) cb(r, ud); - /* Push a copy onto polling queue so callback users and - * polling users are independent. */ + /* 将副本压入轮询队列,使回调用户与轮询用户相互独立。 */ 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); @@ -972,7 +964,7 @@ static void csm_dispatch_packet(csm_client_t *c, csm_packet_t *pkt) { } csm_mutex_unlock(&c->sub_lock); if (cb) cb(n, ud); - /* Push a copy onto polling queue. */ + /* 将副本压入轮询队列。 */ csm_status_notification_t *q = (csm_status_notification_t *)calloc(1, sizeof(*q)); if (q) { q->packet_type = n->packet_type; @@ -996,7 +988,7 @@ static void csm_dispatch_packet(csm_client_t *c, csm_packet_t *pkt) { case CSM_PT_INFO: case CSM_PT_CMD: default: - /* INFO is silently discarded; CMD never sent by server. */ + /* INFO 静默丢弃;CMD 不由服务器发送。 */ csm_packet_free_void(pkt); return; } @@ -1011,9 +1003,9 @@ static void *csm_recv_thread_main(void *arg) csm_client_t *c = (csm_client_t *)arg; uint8_t header[CSM_HEADER_SIZE]; for (;;) { - /* Snapshot stop_flag and sock under state_lock. csm_client_disconnect() - * mutates both fields under the same lock, so a torn read or a stale - * sock value cannot occur and TSAN/UBSan stay quiet. */ + /* 在 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; @@ -1036,10 +1028,10 @@ static void *csm_recv_thread_main(void *arg) csm_result_t r = csm_parse_packet(header, CSM_HEADER_SIZE, body, data_len, &parsed); free(body); if (r != CSM_OK) { - /* Skip corrupt frame; keep loop alive. */ + /* 跳过损坏帧;保持循环运行。 */ continue; } - /* Allocate heap copy to pass ownership to dispatch. */ + /* 分配堆副本以将所有权传递给派发函数。 */ csm_packet_t *heap_pkt = (csm_packet_t *)malloc(sizeof(*heap_pkt)); if (!heap_pkt) { csm_packet_dispose(&parsed); @@ -1049,7 +1041,7 @@ static void *csm_recv_thread_main(void *arg) csm_dispatch_packet(c, heap_pkt); } - /* Notify any blocked waiters that the connection is gone. */ + /* 通知所有阻塞的等待者连接已断开。 */ 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); @@ -1065,7 +1057,7 @@ static void *csm_recv_thread_main(void *arg) #endif } -/* --- Lifecycle --- */ +/* --- 生命周期 --- */ csm_client_t *csm_client_create(void) { if (csm_wsa_startup() != 0) return NULL; @@ -1103,8 +1095,8 @@ void csm_client_destroy(csm_client_t *client) { csm_wsa_cleanup(); } -/* Resolve host and connect with a timeout. Returns CSM_OK or - * CSM_ERR_CONNECTION / CSM_ERR_TIMEOUT. */ +/* 解析主机名并在超时限制内建立连接。返回 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) { @@ -1126,7 +1118,7 @@ static csm_result_t csm_do_connect(const char *host, uint16_t port, sock = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol); if (sock == CSM_INVALID_SOCKET) continue; - /* Switch to non-blocking for connect-with-timeout. */ + /* 切换为非阻塞模式以实现带超时的 connect。 */ #if defined(_WIN32) u_long mode = 1; ioctlsocket(sock, FIONBIO, &mode); @@ -1172,7 +1164,7 @@ static csm_result_t csm_do_connect(const char *host, uint16_t port, } if (result == CSM_OK) { - /* Switch back to blocking for the recv loop. */ + /* 切换回阻塞模式供接收循环使用。 */ #if defined(_WIN32) u_long mode2 = 0; ioctlsocket(sock, FIONBIO, &mode2); @@ -1253,7 +1245,7 @@ csm_result_t csm_client_disconnect(csm_client_t *client) { client->sock = CSM_INVALID_SOCKET; csm_mutex_unlock(&client->state_lock); - /* Wake any blocked waiters before tearing down the socket. */ + /* 在关闭套接字前唤醒所有阻塞的等待者。 */ 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); @@ -1282,7 +1274,7 @@ csm_result_t csm_client_disconnect(csm_client_t *client) { int csm_client_is_connected(const csm_client_t *client) { if (!client) return 0; - /* Casting away const to take the lock; logically this is a read. */ + /* 去除 const 以获取锁;逻辑上这是一次读操作。 */ csm_client_t *mc = (csm_client_t *)client; csm_mutex_lock(&mc->state_lock); int v = mc->connected; @@ -1312,7 +1304,7 @@ csm_result_t csm_client_wait_for_server(const char *host, return result; } -/* --- Send helpers --- */ +/* --- 发送辅助函数 --- */ static csm_result_t csm_send_raw(csm_client_t *client, const uint8_t *data, size_t len) { @@ -1351,7 +1343,7 @@ static csm_result_t csm_send_raw(csm_client_t *client, return CSM_OK; } -/* Pack and send a CMD packet. */ +/* 打包并发送 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); @@ -1364,7 +1356,7 @@ static csm_result_t csm_send_cmd(csm_client_t *client, const char *command) { return r; } -/* --- Wait helpers --- */ +/* --- 等待辅助函数 --- */ static csm_result_t csm_wait_for_resp(csm_client_t *client, unsigned int timeout_ms, @@ -1399,13 +1391,13 @@ static csm_result_t csm_wait_for_cmd_resp(csm_client_t *client, return CSM_ERR_SERVER; } if (r != CSM_OK) return r; - /* Discard handshake payload. */ + /* 丢弃握手载荷。 */ csm_packet_t *pkt = (csm_packet_t *)item; csm_packet_free_void(pkt); return CSM_OK; } -/* --- Public command API --- */ +/* --- 公共命令 API --- */ csm_result_t csm_client_send_and_wait(csm_client_t *client, const char *command, @@ -1449,7 +1441,7 @@ csm_result_t csm_client_ping(csm_client_t *client, unsigned int timeout_ms, return CSM_OK; } -/* Shared helper: send a fixed-text command and return the response text. */ +/* 共享辅助函数:发送固定文本命令并返回响应文本。 */ static csm_result_t csm_send_text_query(csm_client_t *client, const char *command, char **out_text, @@ -1459,7 +1451,7 @@ static csm_result_t csm_send_text_query(csm_client_t *client, 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; /* transfer ownership; was NUL-terminated */ + *out_text = (char *)resp.raw; /* 转移所有权;已以 NUL 结尾 */ resp.raw = NULL; } else { csm_command_response_dispose(&resp); @@ -1472,7 +1464,7 @@ csm_result_t csm_client_list_modules(csm_client_t *client, char **out_text, return csm_send_text_query(client, "List", out_text, timeout_ms); } -/* Build a " " command and send. */ +/* 构造 " " 命令并发送。 */ static csm_result_t csm_send_text_query_2(csm_client_t *client, const char *prefix, const char *module, @@ -1507,7 +1499,7 @@ csm_result_t csm_client_help(csm_client_t *client, const char *module, return csm_send_text_query_2(client, "Help", module, out_text, timeout_ms); } -/* --- Subscriptions --- */ +/* --- 订阅 --- */ csm_result_t csm_client_subscribe_status(csm_client_t *client, const char *status_name, @@ -1517,13 +1509,12 @@ csm_result_t csm_client_subscribe_status(csm_client_t *client, unsigned int timeout_ms) { if (!client || !status_name || !module_name) return CSM_ERR_INVALID; - /* Register first to eliminate the race where a STATUS arrives before - * the callback is stored. */ + /* 先注册,以消除 STATUS 在回调存储前到达的竞态条件。 */ csm_result_t r = csm_register_status_sub(client, status_name, module_name, callback, user_data); if (r != CSM_OK) return r; - /* Build "@ ->". */ + /* 构造 "@ ->"。 */ size_t s_len = strlen(status_name); size_t m_len = strlen(module_name); const char *suffix = " ->"; @@ -1588,7 +1579,7 @@ csm_result_t csm_client_unregister_async_callback(csm_client_t *client, return CSM_OK; } -/* --- Polling queues --- */ +/* --- 轮询队列 --- */ csm_result_t csm_client_poll_status(csm_client_t *client, csm_status_notification_t *out_notif, @@ -1599,7 +1590,7 @@ csm_result_t csm_client_poll_status(csm_client_t *client, 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; - /* Move ownership of fields from src to out_notif. */ + /* 将字段所有权从 src 移至 out_notif。 */ *out_notif = *src; free(src); return CSM_OK; diff --git a/SDK/c/tests/mock_server.c b/SDK/c/tests/mock_server.c index b16708d..7c62f4b 100644 --- a/SDK/c/tests/mock_server.c +++ b/SDK/c/tests/mock_server.c @@ -1,4 +1,4 @@ -/* mock_server.c - cross-platform implementation of the test mock server. */ +/* mock_server.c - 测试模拟服务器的跨平台实现。 */ #if !defined(_WIN32) # ifndef _POSIX_C_SOURCE @@ -38,7 +38,7 @@ 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 /* reserved for future use */ +#if 0 /* 保留供将来使用 */ static void ms_sleep_ms(unsigned int ms){Sleep(ms);} #endif #else @@ -71,7 +71,7 @@ static int ms_cond_wait_ms(ms_cond_t *c, ms_mutex_t *m, unsigned int ms){ 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 /* reserved for future use */ +#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); @@ -87,7 +87,7 @@ static void ms_sleep_ms(unsigned int ms){ #define MS_HEADER 8 #define MS_VER 0x01 -/* --- response map --- */ +/* --- 响应映射 --- */ typedef struct ms_resp { struct ms_resp *next; char *cmd; @@ -96,7 +96,7 @@ typedef struct ms_resp { size_t data_len; } ms_resp_t; -/* --- received command queue --- */ +/* --- 已接收命令队列 --- */ typedef struct ms_msg { struct ms_msg *next; char *text; @@ -146,7 +146,7 @@ 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]; } -/* Encode header + payload into newly-allocated buffer; caller frees. */ +/* 将头部 + 载荷编码到新分配的缓冲区中;调用者负责释放。 */ 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; @@ -191,7 +191,7 @@ static int ms_recv_all(ms_socket_t s, uint8_t *buf, size_t len) { return 0; } -/* --- Public API --- */ +/* --- 公共 API --- */ csm_mock_server_t *csm_mock_server_create(void) { if (ms_wsa_init() != 0) return NULL; @@ -210,7 +210,7 @@ csm_mock_server_t *csm_mock_server_create(void) { static void ms_handle_command(csm_mock_server_t *s, ms_socket_t conn, const char *cmd) { - /* Look up custom response. */ + /* 查找自定义响应。 */ ms_mutex_lock(&s->resp_lock); ms_resp_t *r = s->responses; while (r) { @@ -225,7 +225,7 @@ static void ms_handle_command(csm_mock_server_t *s, ms_socket_t conn, } ms_mutex_unlock(&s->resp_lock); - /* Built-in defaults. */ + /* 内置默认值。 */ size_t out_len = 0; uint8_t *wire = NULL; if (strcmp(cmd, "Ping") == 0) { @@ -246,7 +246,7 @@ static void ms_handle_command(csm_mock_server_t *s, ms_socket_t conn, } else if (strstr(cmd, "->") || strstr(cmd, "->")) { wire = ms_encode(CSM_PT_CMD_RESP, "", 0, &out_len); } else { - /* Generic async handshake. */ + /* 通用异步握手。 */ wire = ms_encode(CSM_PT_CMD_RESP, "", 0, &out_len); } if (wire) { ms_send_all(conn, wire, out_len); free(wire); } @@ -264,7 +264,7 @@ ms_client_thread(void *arg) { ms_socket_t conn = ctx->conn; free(ctx); - /* Send welcome INFO. */ + /* 发送欢迎 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); } @@ -285,15 +285,15 @@ ms_client_thread(void *arg) { if (data_len) memcpy(cmd, body, data_len); cmd[data_len] = '\0'; - /* Trim trailing whitespace. */ + /* 修剪尾部空白。 */ 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'; - /* Handle the command first (using the local copy). */ + /* 先处理命令(使用本地副本)。 */ ms_handle_command(s, conn, cmd); - /* Then enqueue a copy for the test to inspect. */ + /* 然后将副本入队供测试检查。 */ ms_msg_t *m = (ms_msg_t *)calloc(1, sizeof(*m)); if (m) { m->text = (char *)malloc(strlen(cmd) + 1); @@ -315,7 +315,7 @@ ms_client_thread(void *arg) { free(body); } - /* Remove from clients list. */ + /* 从客户端列表中移除。 */ 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; } @@ -430,7 +430,7 @@ void csm_mock_server_stop(csm_mock_server_t *s) { ms_close_socket(s->listen_sock); s->listen_sock = MS_INVALID_SOCKET; } - /* Close client sockets to wake handlers. */ + /* 关闭客户端套接字以唤醒处理程序。 */ ms_mutex_lock(&s->client_lock); for (int i = 0; i < MS_MAX_CLIENTS; ++i) { if (s->clients[i] != MS_INVALID_SOCKET) { @@ -454,7 +454,7 @@ void csm_mock_server_stop(csm_mock_server_t *s) { #endif s->thread_started = 0; } - /* Wait for handlers to finish (best-effort, brief). */ + /* 等待处理程序完成(尽力而为,时间较短)。 */ ms_mutex_lock(&s->handler_lock); int waited = 0; while (s->handler_count > 0 && waited < 20) { diff --git a/SDK/c/tests/mock_server.h b/SDK/c/tests/mock_server.h index 1c8f611..8ac56d0 100644 --- a/SDK/c/tests/mock_server.h +++ b/SDK/c/tests/mock_server.h @@ -1,6 +1,6 @@ -/* mock_server.h - In-process TCP server emulating a CSM-TCP-Router for tests. +/* mock_server.h - 用于测试的进程内 TCP 服务器,模拟 CSM-TCP-Router。 * - * Mirrors the Python `tests/conftest.py` MockServer fixture. + * 对应 Python `tests/conftest.py` 中的 MockServer 夹具。 */ #ifndef CSM_MOCK_SERVER_H #define CSM_MOCK_SERVER_H @@ -11,38 +11,38 @@ typedef struct csm_mock_server csm_mock_server_t; -/** Create a stopped mock server bound to 127.0.0.1; the actual port is - * assigned by the OS in csm_mock_server_start(). */ +/** 创建一个已停止的模拟服务器,绑定到 127.0.0.1; + * 实际端口由操作系统在 csm_mock_server_start() 中分配。 */ csm_mock_server_t *csm_mock_server_create(void); -/** Free a (running or stopped) mock server. */ +/** 释放(运行中或已停止的)模拟服务器。 */ void csm_mock_server_destroy(csm_mock_server_t *s); -/** Bind to 127.0.0.1, an ephemeral port, and start the accept thread. */ +/** 绑定到 127.0.0.1 的临时端口,并启动接受连接线程。 */ int csm_mock_server_start(csm_mock_server_t *s); -/** Stop the accept thread and close all client connections. */ +/** 停止接受连接线程并关闭所有客户端连接。 */ void csm_mock_server_stop(csm_mock_server_t *s); -/** Return the port the server is listening on (valid after start()). */ +/** 返回服务器正在监听的端口(start() 之后有效)。 */ uint16_t csm_mock_server_port(const csm_mock_server_t *s); -/** Register a custom RESP reply for an exact command string. */ +/** 为精确匹配的命令字符串注册自定义 RESP 回复。 */ void csm_mock_server_set_response(csm_mock_server_t *s, const char *cmd_text, const char *resp_text); -/** Register an ERROR reply for an exact command string. */ +/** 为精确匹配的命令字符串注册 ERROR 回复。 */ void csm_mock_server_set_error_response(csm_mock_server_t *s, const char *cmd_text, const char *error_text); -/** Push a STATUS packet to all currently connected clients. */ +/** 向所有当前连接的客户端推送 STATUS 数据包。 */ void csm_mock_server_push_status(csm_mock_server_t *s, const char *payload); -/** Pop the next received command, blocking up to *timeout_ms*. The returned - * string is owned by the caller and must be freed with csm_string_free. - * Returns NULL on timeout. */ +/** 弹出下一条已接收的命令,最多阻塞 *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 index 279447d..2d949cd 100644 --- a/SDK/c/tests/test_client.c +++ b/SDK/c/tests/test_client.c @@ -1,5 +1,5 @@ -/* test_client.c - Unit-level tests for the client object lifecycle that do - * not require a running mock server. */ +/* test_client.c - 客户端对象生命周期的单元测试, + * 无需运行中的模拟服务器。 */ #include "csm_tcp_router_client.h" #include "test_harness.h" @@ -37,7 +37,7 @@ CSM_TEST(test_invalid_args_rejected) { } CSM_TEST(test_wait_for_server_unreachable_times_out) { - /* Pick an arbitrary high port that should not be in use. */ + /* 选择一个不太可能被使用的任意高端口。 */ csm_result_t r = csm_client_wait_for_server("127.0.0.1", 1, 200, 50); CSM_ASSERT_EQ_INT(r, CSM_ERR_TIMEOUT); } @@ -45,7 +45,7 @@ CSM_TEST(test_wait_for_server_unreachable_times_out) { 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); - /* Either CSM_ERR_CONNECTION (refused) or CSM_ERR_TIMEOUT depending on OS. */ + /* 根据操作系统,返回 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 index ad123c3..568347c 100644 --- a/SDK/c/tests/test_harness.h +++ b/SDK/c/tests/test_harness.h @@ -1,10 +1,8 @@ -/* test_harness.h - Minimal in-process unit-test harness used by the - * csm-tcp-router-client C SDK tests. +/* test_harness.h - csm-tcp-router-client C SDK 测试使用的极简进程内单元测试框架。 * - * Tests register themselves via the CSM_TEST() macro; the runner in - * test_main.c picks them up via the link-time TESTS array, executes them, - * and prints a summary. Failures abort the current test only; assertions - * use longjmp to unwind back to the runner. + * 测试通过 CSM_TEST() 宏注册自身;test_main.c 中的运行器 + * 通过链接时的 TESTS 数组收集测试,依次执行并打印汇总结果。 + * 失败仅中止当前测试;断言使用 longjmp 回退到运行器。 */ #ifndef CSM_TEST_HARNESS_H #define CSM_TEST_HARNESS_H @@ -20,7 +18,7 @@ typedef struct { csm_test_fn fn; } csm_test_t; -/* Provided by test_main.c. */ +/* 由 test_main.c 提供。 */ extern jmp_buf csm_test_jmp; extern int csm_test_failed; extern int csm_test_assertions; @@ -54,8 +52,8 @@ extern int csm_test_assertions; _b ? _b : "(null)", _a ? _a : "(null)"); \ } while (0) -/* Define a test function. The runner declares each test as extern via the - * CSM_TEST_EXTERN macro and registers it in its tests table. */ +/* 定义一个测试函数。运行器通过 CSM_TEST_EXTERN 宏将每个测试声明为 extern, + * 并在测试表中注册。 */ #define CSM_TEST(name) void name(void) #define CSM_TEST_EXTERN(name) extern void name(void) diff --git a/SDK/c/tests/test_integration.c b/SDK/c/tests/test_integration.c index 24f1a86..75a3aa2 100644 --- a/SDK/c/tests/test_integration.c +++ b/SDK/c/tests/test_integration.c @@ -1,4 +1,4 @@ -/* test_integration.c - End-to-end tests against the in-process MockServer. */ +/* test_integration.c - 针对进程内 MockServer 的端到端测试。 */ #if !defined(_WIN32) # ifndef _POSIX_C_SOURCE @@ -25,7 +25,7 @@ static void it_sleep_ms(unsigned int ms) { } #endif -/* Helper: spin up server + connect a client. */ +/* 辅助函数:启动服务器 + 连接客户端。 */ 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); @@ -118,7 +118,7 @@ CSM_TEST(it_post_async_handshake) { static void it_status_cb(const csm_status_notification_t *n, void *ud) { int *count = (int *)ud; (*count)++; - /* Sanity-check parsed fields. */ + /* 完整性检查已解析的字段。 */ (void)n; } @@ -129,16 +129,16 @@ CSM_TEST(it_subscribe_status_invokes_callback) { csm_result_t r = csm_client_subscribe_status(c, "Status", "DAQmx", it_status_cb, &count, 1000); CSM_ASSERT_EQ_INT(r, CSM_OK); - /* Drain handshake from received queue. */ + /* 从已接收队列中消耗握手数据。 */ char *cmd = csm_mock_server_get_received(s, 500); csm_string_free(cmd); - /* Push a STATUS notification matching the subscription. */ + /* 推送一条匹配订阅的 STATUS 通知。 */ csm_mock_server_push_status(s, "Status >> 1.23 <- DAQmx"); - /* Wait for callback (poll up to 1s). */ + /* 等待回调(最多轮询 1 秒)。 */ for (int i = 0; i < 100 && count == 0; ++i) it_sleep_ms(10); CSM_ASSERT(count >= 1); - /* Same notification should also be available via polling. */ + /* 同一通知也应可通过轮询获取。 */ csm_status_notification_t n = {0}; r = csm_client_poll_status(c, &n, 500); CSM_ASSERT_EQ_INT(r, CSM_OK); @@ -154,8 +154,8 @@ CSM_TEST(it_subscribe_status_invokes_callback) { CSM_TEST(it_disconnect_unblocks_waiters) { csm_mock_server_t *s = NULL; csm_client_t *c = NULL; it_setup(&s, &c); - /* Send a command that has no canned response; mock returns CMD_RESP. */ - /* For this test simply disconnect immediately and verify subsequent send fails. */ + /* 发送一个没有预置响应的命令;模拟服务器返回 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); diff --git a/SDK/c/tests/test_main.c b/SDK/c/tests/test_main.c index 4a6628b..5fcdad9 100644 --- a/SDK/c/tests/test_main.c +++ b/SDK/c/tests/test_main.c @@ -1,4 +1,4 @@ -/* test_main.c - Runner for the C SDK test suite. */ +/* test_main.c - C SDK 测试套件的运行器。 */ #include "test_harness.h" #include @@ -64,8 +64,7 @@ static const csm_test_t TESTS[] = { int main(int argc, char **argv) { const char *only = (argc > 1) ? argv[1] : NULL; - /* `volatile` ensures these survive the longjmp performed by failing - * assertions inside individual test bodies. */ + /* `volatile` 确保这些变量在各个测试体内的失败断言执行 longjmp 后仍然存活。 */ volatile int passed = 0, failed = 0, skipped = 0; volatile int total_assertions = 0; size_t n = sizeof(TESTS) / sizeof(TESTS[0]); diff --git a/SDK/c/tests/test_protocol.c b/SDK/c/tests/test_protocol.c index 03f509d..a8c9e8a 100644 --- a/SDK/c/tests/test_protocol.c +++ b/SDK/c/tests/test_protocol.c @@ -1,4 +1,4 @@ -/* test_protocol.c - Unit tests for the protocol codec. */ +/* test_protocol.c - 协议编解码的单元测试。 */ #include "csm_tcp_router_client.h" #include "test_harness.h" @@ -18,7 +18,7 @@ CSM_TEST(test_encode_decode_roundtrip) { CSM_ASSERT_EQ_INT(r, CSM_OK); CSM_ASSERT_EQ_INT(out_len, 8 + 5); - /* Header bytes: big-endian length, version, type, flag1, flag2. */ + /* 头部字节:大端序长度、版本、类型、flag1、flag2。 */ CSM_ASSERT_EQ_INT(buf[0], 0); CSM_ASSERT_EQ_INT(buf[1], 0); CSM_ASSERT_EQ_INT(buf[2], 0); diff --git a/SDK/csharp/examples/BasicUsage/Program.cs b/SDK/csharp/examples/BasicUsage/Program.cs index 7384c05..2bdd24a 100644 --- a/SDK/csharp/examples/BasicUsage/Program.cs +++ b/SDK/csharp/examples/BasicUsage/Program.cs @@ -4,11 +4,11 @@ namespace CsmTcpRouter.Examples.BasicUsage { /// - /// Basic usage example for csm-tcp-router-client (C#). - /// Mirrors SDK/python/examples/basic_usage.py. + /// csm-tcp-router-client(C#)的基本用法示例。 + /// 镜像 SDK/python/examples/basic_usage.py。 /// - /// Prerequisites: a running CSM-TCP-Router server (LabVIEW app). - /// The reference app defaults to port 30007. + /// 前提条件:正在运行的 CSM-TCP-Router 服务器(LabVIEW 应用程序)。 + /// 参考应用程序默认使用端口 30007。 /// public static class Program { @@ -17,7 +17,7 @@ public static class Program public static int Main(string[] args) { - // 1. Wait until the server is ready. + // 1. 等待服务器就绪。 Console.Write("Waiting for server ... "); using (var probe = new TcpRouterClient()) { @@ -30,7 +30,7 @@ public static int Main(string[] args) } Console.WriteLine("ready."); - // 2. Connect (use as IDisposable so Disconnect is always called). + // 2. 连接(使用 IDisposable,确保始终调用 Disconnect)。 using (var client = new TcpRouterClient()) { try @@ -45,17 +45,17 @@ public static int Main(string[] args) Console.WriteLine($"Connected to {Host}:{Port}"); - // 3. Ping + // 3. Ping 测试 var (ok, elapsed) = client.Ping(TimeSpan.FromSeconds(2)); Console.WriteLine(ok ? $"Ping OK latency={elapsed.TotalMilliseconds:F1} ms" : "Ping failed."); - // 4. List CSM modules + // 4. 列出 CSM 模块 string modules = client.ListModules(); Console.WriteLine($"\nLoaded modules:\n{modules}"); - // 5. List API for the first module (if any) + // 5. 列出第一个模块的 API(如有) string firstModule = null; foreach (var line in modules.Split('\n')) { @@ -72,14 +72,14 @@ public static int Main(string[] args) Console.WriteLine($"\nAPI for '{firstModule}':\n{api}"); } - // 6. Send a synchronous command (uncomment & adapt for your CSM): + // 6. 发送同步命令(取消注释并适配您的 CSM): // var resp = client.SendAndWait("API: Read -@ DAQmx"); // Console.WriteLine($"\nSync response: {resp.Text}"); - // 7. Send an async command + wait for cmd-resp handshake: + // 7. 发送异步命令并等待 cmd-resp 握手: // client.Post("API: Start Sampling -> DAQmx"); - // 8. Send a no-reply command: + // 8. 发送无回复命令: // client.PostNoReply("API: Reset ->| DAQmx"); Console.WriteLine("\nDone."); diff --git a/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs b/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs index 1a73673..6d1fa91 100644 --- a/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs +++ b/SDK/csharp/src/CsmTcpRouter/CsmTcpRouter.cs @@ -1,27 +1,27 @@ // CsmTcpRouter.cs // --------------------------------------------------------------------------- -// csm-tcp-router-client - C# client SDK for the CSM-TCP-Router LabVIEW server. +// csm-tcp-router-client - CSM-TCP-Router LabVIEW 服务器的 C# 客户端 SDK。 // -// Single-file SDK implementing CSM-TCP-Router protocol v0. Mirrors the -// Python `csm_tcp_router` package layout and feature set: +// 单文件 SDK,实现 CSM-TCP-Router 协议 v0。镜像了 +// Python `csm_tcp_router` 包的布局和功能: // -// * Protocol codec (8-byte header, big-endian, 8 packet types). -// * Background-receiver TCP transport. -// * High-level TcpRouterClient with sync and async APIs: -// - SendAndWait / SendAndWaitAsync (synchronous CMD/RESP) -// - Post / PostAsync (async CMD with cmd-resp handshake) -// - PostNoReply / PostNoReplyAsync (no-reply async CMD) -// - Ping / PingAsync (round-trip latency) +// * 协议编解码器(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 // -// Wire format (8-byte header, big-endian):: +// 线路格式(8 字节头,大端序): // // | Data Length (4B) | Version (1B=0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | // +------------------------ Header (8B) -----------------------+ // -// followed by exactly `Data Length` bytes of payload. +// 后跟恰好 `Data Length` 字节的有效载荷。 // // Copyright (c) 2026 NEVSTOP-LAB. Released under the MIT License. // --------------------------------------------------------------------------- @@ -42,37 +42,37 @@ namespace CsmTcpRouter { // ----------------------------------------------------------------------- - // Public enumerations + // 公共枚举 // ----------------------------------------------------------------------- /// - /// Packet type constants as defined in the CSM-TCP-Router protocol v0. + /// CSM-TCP-Router 协议 v0 中定义的数据包类型常量。 /// public enum PacketType : byte { - /// Informational message (welcome / goodbye). + /// 信息消息(欢迎/再见)。 Info = 0x00, - /// Error packet from the server. + /// 来自服务器的错误数据包。 Error = 0x01, - /// Command sent by the client. + /// 客户端发送的命令。 Cmd = 0x02, - /// Server handshake for async / no-reply / subscribe. + /// 服务器对异步/无回复/订阅命令的握手确认。 CmdResp = 0x03, - /// Synchronous response payload. + /// 同步响应有效载荷。 Resp = 0x04, - /// Asynchronous response payload. + /// 异步响应有效载荷。 AsyncResp = 0x05, - /// Status broadcast from a subscribed CSM module. + /// 来自已订阅 CSM 模块的状态广播。 Status = 0x06, - /// Interrupt broadcast from a subscribed CSM module. + /// 来自已订阅 CSM 模块的中断广播。 Interrupt = 0x07, } // ----------------------------------------------------------------------- - // Public data models + // 公共数据模型 // ----------------------------------------------------------------------- - /// A decoded packet received from the server. + /// 从服务器接收到的已解码数据包。 public sealed class Packet { public PacketType Type { get; } @@ -91,7 +91,7 @@ public Packet(PacketType type, byte[] data, byte version = 1, byte flag1 = 0, by } } - /// The result of a synchronous command (). + /// 同步命令()的结果。 public sealed class CommandResponse { public byte[] Raw { get; } @@ -105,7 +105,7 @@ public CommandResponse(byte[] raw) public override string ToString() => $"CommandResponse(\"{Text}\")"; } - /// An asynchronous response payload delivered via an async-resp packet. + /// 通过异步响应数据包传递的异步响应有效载荷。 public sealed class AsyncResponse { public byte[] Raw { get; } @@ -119,8 +119,8 @@ public AsyncResponse(byte[] raw, string originalCommand = "") } /// - /// Parse an ASYNC_RESP packet. Server format: - /// "<response-data> <- <original-command>". + /// 解析 ASYNC_RESP 数据包。服务器格式: + /// "<response-data> <- <original-command>"。 /// public static AsyncResponse FromPacket(Packet packet) { @@ -139,7 +139,7 @@ public static AsyncResponse FromPacket(Packet packet) public override string ToString() => $"AsyncResponse(\"{Text}\", cmd=\"{OriginalCommand}\")"; } - /// A status broadcast delivered via a STATUS or INTERRUPT packet. + /// 通过 STATUS 或 INTERRUPT 数据包传递的状态广播。 public sealed class StatusNotification { public byte[] Raw { get; } @@ -163,8 +163,8 @@ public StatusNotification( } /// - /// Parse a STATUS or INTERRUPT packet. Server format: - /// "<status-name> >> <data> <- <module>". + /// 解析 STATUS 或 INTERRUPT 数据包。服务器格式: + /// "<status-name> >> <data> <- <module>"。 /// public static StatusNotification FromPacket(Packet packet) { @@ -194,10 +194,10 @@ public override string ToString() => } // ----------------------------------------------------------------------- - // Exception hierarchy + // 异常层次结构 // ----------------------------------------------------------------------- - /// Base exception for all CSM-TCP-Router client errors. + /// 所有 CSM-TCP-Router 客户端错误的基础异常。 public class CsmTcpRouterException : Exception { public CsmTcpRouterException() { } @@ -205,28 +205,28 @@ public CsmTcpRouterException(string message) : base(message) { } public CsmTcpRouterException(string message, Exception innerException) : base(message, innerException) { } } - /// Raised when a connection cannot be established or is lost. + /// 当连接无法建立或连接丢失时引发。 public class RouterConnectionException : CsmTcpRouterException { public RouterConnectionException(string message) : base(message) { } public RouterConnectionException(string message, Exception innerException) : base(message, innerException) { } } - /// Raised when a synchronous operation exceeds its timeout. + /// 当同步操作超过其超时时间时引发。 public class RouterTimeoutException : CsmTcpRouterException { public RouterTimeoutException(string message) : base(message) { } } - /// Raised when an invalid or unexpected protocol frame is received. + /// 当收到无效或意外的协议帧时引发。 public class ProtocolException : CsmTcpRouterException { public ProtocolException(string message) : base(message) { } } /// - /// Raised when the server returns an error packet. CSM Error format: - /// [Error: <code>] <message>. + /// 当服务器返回错误数据包时引发。CSM 错误格式: + /// [Error: <code>] <message>。 /// public class ServerException : CsmTcpRouterException { @@ -249,7 +249,7 @@ public override string ToString() } // ----------------------------------------------------------------------- - // Internal protocol codec + // 内部协议编解码器 // ----------------------------------------------------------------------- internal static class ProtocolCodec @@ -257,7 +257,7 @@ internal static class ProtocolCodec public const int HeaderSize = 8; public const byte ProtocolVersion = 0x01; - /// Encode into a complete wire-format packet (header + body). + /// 编码为完整的线路格式数据包(头 + 体)。 public static byte[] EncodePacket(byte[] data, PacketType packetType, byte flag1 = 0, byte flag2 = 0) { data = data ?? Array.Empty(); @@ -275,7 +275,7 @@ public static byte[] EncodePacket(byte[] data, PacketType packetType, byte flag1 return wire; } - /// Decode an 8-byte header into its constituent fields. + /// 将 8 字节头解码为其组成字段。 public static (uint DataLen, byte Version, byte TypeByte, byte Flag1, byte Flag2) DecodeHeader(byte[] header) { if (header == null || header.Length != HeaderSize) @@ -285,7 +285,7 @@ public static (uint DataLen, byte Version, byte TypeByte, byte Flag1, byte Flag2 return (dataLen, header[4], header[5], header[6], header[7]); } - /// Build a from raw header + body. + /// 从原始头 + 体构建 public static Packet ParsePacket(byte[] header, byte[] body) { var (dataLen, version, typeByte, flag1, flag2) = DecodeHeader(header); @@ -293,14 +293,14 @@ public static Packet ParsePacket(byte[] header, byte[] body) if ((uint)body.Length != dataLen) throw new ProtocolException( $"Payload length mismatch: header says {dataLen} bytes, got {body.Length} bytes."); - // Forward-compatible: unknown type bytes are mapped to Info. + // 向前兼容:未知类型字节映射为 Info。 PacketType ptype = Enum.IsDefined(typeof(PacketType), typeByte) ? (PacketType)typeByte : PacketType.Info; return new Packet(ptype, body, version, flag1, flag2); } - /// Extract code and message from a CSM Error format [Error: code] msg. + /// 从 CSM 错误格式 [Error: code] msg 中提取代码和消息。 public static ServerException ParseServerError(Packet packet) { string text = Encoding.UTF8.GetString(packet.Data).Trim(); @@ -320,7 +320,7 @@ public static ServerException ParseServerError(Packet packet) } // ----------------------------------------------------------------------- - // Internal TCP transport (background receive task) + // 内部 TCP 传输层(后台接收任务) // ----------------------------------------------------------------------- internal sealed class Transport : IDisposable @@ -367,11 +367,11 @@ public async Task ConnectAsync(string host, int port, TimeSpan? timeout = null) var winner = await Task.WhenAny(connectTask, Task.Delay(to)).ConfigureAwait(false); if (winner != connectTask) { - try { client.Close(); } catch { /* ignore */ } + try { client.Close(); } catch { /* 忽略 */ } throw new RouterConnectionException( $"Cannot connect to {host}:{port}: timed out after {to.TotalSeconds:F1}s."); } - await connectTask.ConfigureAwait(false); // surface any connect exception + await connectTask.ConfigureAwait(false); // 让任何连接异常浮现 } catch (RouterConnectionException) { @@ -379,7 +379,7 @@ public async Task ConnectAsync(string host, int port, TimeSpan? timeout = null) } catch (Exception exc) { - try { client.Close(); } catch { /* ignore */ } + try { client.Close(); } catch { /* 忽略 */ } throw new RouterConnectionException($"Cannot connect to {host}:{port}: {exc.Message}", exc); } @@ -393,15 +393,15 @@ public async Task ConnectAsync(string host, int port, TimeSpan? timeout = null) public void Disconnect(TimeSpan? joinTimeout = null) { _stopped = true; - try { _cts?.Cancel(); } catch { /* ignore */ } - try { _stream?.Close(); } catch { /* ignore */ } - try { _client?.Close(); } catch { /* ignore */ } + 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 { /* ignore */ } + try { _recvTask?.Wait(jt); } catch { /* 忽略 */ } _recvTask = null; - try { _cts?.Dispose(); } catch { /* ignore */ } + try { _cts?.Dispose(); } catch { /* 忽略 */ } _cts = null; } @@ -427,7 +427,7 @@ public void SendRaw(byte[] data) public void Dispose() => Disconnect(); // --------------------------------------------------------------- - // Internal: background receive loop + // 内部:后台接收循环 // --------------------------------------------------------------- private async Task RecvLoopAsync(CancellationToken ct) @@ -453,22 +453,22 @@ private async Task RecvLoopAsync(CancellationToken ct) } catch (ProtocolException) { - // Corrupted frame -- skip it and keep the loop alive. + // 损坏的帧——跳过并保持循环运行。 continue; } try { _onPacket(packet); } catch { /* swallow callback errors */ } } } - catch (IOException) { /* connection dropped */ } - catch (ObjectDisposedException) { /* socket closed during read */ } - catch (OperationCanceledException) { /* shutdown */ } + catch (IOException) { /* 连接已断开 */ } + catch (ObjectDisposedException) { /* 读取期间套接字已关闭 */ } + catch (OperationCanceledException) { /* 正在关闭 */ } finally { if (!_stopped) { _stopped = true; - try { _onDisconnect(); } catch { /* ignore */ } + try { _onDisconnect(); } catch { /* 忽略 */ } } } } @@ -493,27 +493,26 @@ private static async Task ReadExactlyAsync(Stream stream, byte[] buf, int } // ----------------------------------------------------------------------- - // High-level client + // 高层客户端 // ----------------------------------------------------------------------- - /// Callback delegate for status / interrupt broadcasts. + /// 用于状态/中断广播的回调委托。 public delegate void StatusCallback(StatusNotification notification); - /// Callback delegate for asynchronous-response packets. + /// 用于异步响应数据包的回调委托。 public delegate void AsyncResponseCallback(AsyncResponse response); /// - /// C# client for a CSM-TCP-Router server. Mirrors the LabVIEW ClientAPI - /// VIs and the Python TcpRouterClient; speaks protocol v0. + /// CSM-TCP-Router 服务器的 C# 客户端。镜像了 LabVIEW ClientAPI VI + /// 和 Python TcpRouterClient;使用协议 v0。 /// - /// The class is thread-safe. At most one in-flight synchronous command - /// and one in-flight async / subscription command may be outstanding at a - /// time; concurrent callers are serialised by internal semaphores. + /// 该类是线程安全的。任意时刻最多只能有一个正在执行的同步命令 + /// 和一个正在执行的异步/订阅命令;并发调用者由内部信号量序列化。 /// public sealed class TcpRouterClient : IDisposable { - // One-item-deep "queues" for synchronised waits, implemented via TCS. - // Reset to a fresh TCS by each waiter inside the corresponding lock. + // 通过 TCS 实现的单元素"队列",用于同步等待。 + // 每个等待者在相应的锁内将其重置为新的 TCS。 private TaskCompletionSource _respTcs; private TaskCompletionSource _cmdRespTcs; @@ -528,10 +527,10 @@ private readonly Dictionary _asyncCallbacks private readonly Transport _transport; - /// Polling queue for async-resp packets received from the server. + /// 用于轮询从服务器接收到的异步响应数据包的队列。 public ConcurrentQueue AsyncResponseQueue { get; } = new ConcurrentQueue(); - /// Polling queue for status / interrupt notifications. + /// 用于轮询状态/中断通知的队列。 public ConcurrentQueue StatusQueue { get; } = new ConcurrentQueue(); public TcpRouterClient() @@ -540,42 +539,41 @@ public TcpRouterClient() } // --------------------------------------------------------------- - // Connection management + // 连接管理 // --------------------------------------------------------------- - /// Connect to a CSM-TCP-Router server. + /// 连接到 CSM-TCP-Router 服务器。 public void Connect(string host, int port, TimeSpan? timeout = null) => _transport.Connect(host, port, timeout); - /// Connect to a CSM-TCP-Router server (async). + /// 连接到 CSM-TCP-Router 服务器(异步)。 public Task ConnectAsync(string host, int port, TimeSpan? timeout = null) => _transport.ConnectAsync(host, port, timeout); /// - /// Disconnect from the server and release all resources. Any threads - /// currently blocked in / - /// will receive a immediately - /// rather than waiting for their timeout to expire. + /// 从服务器断开连接并释放所有资源。当前阻塞在 + /// / 中的线程将立即 + /// 收到 ,而不是等待超时。 /// public void Disconnect() { - // Unblock any pending waiters before tearing down the transport. + // 在拆除传输层之前解除所有挂起等待者的阻塞。 var sentinel = new RouterConnectionException("Disconnected from server."); UnblockWaiters(sentinel); _transport.Disconnect(); } - /// true when the underlying transport is connected. + /// 当底层传输层已连接时为 true public bool Connected => _transport.Connected; /// - /// Poll until : accepts - /// a connection or elapses. + /// 轮询直到 : 接受连接 + /// 或 超时。 /// public bool WaitForServer(string host, int port, TimeSpan? timeout = null, TimeSpan? retryInterval = null) => WaitForServerAsync(host, port, timeout, retryInterval).GetAwaiter().GetResult(); - /// Async version of . + /// 的异步版本。 public async Task WaitForServerAsync( string host, int port, TimeSpan? timeout = null, TimeSpan? retryInterval = null) { @@ -591,28 +589,27 @@ public async Task WaitForServerAsync( { try { - // Observe any connect exception (faulted task); - // success means the server is reachable. + // 观察任何连接异常(已故障任务); + // 成功表示服务器可访问。 await connectTask.ConfigureAwait(false); - try { probe.Close(); } catch { /* ignore */ } + try { probe.Close(); } catch { /* 忽略 */ } return true; } - catch (SocketException) { /* not ready yet */ } - catch (IOException) { /* not ready yet */ } + catch (SocketException) { /* 尚未就绪 */ } + catch (IOException) { /* 尚未就绪 */ } } else { - // Delay won; abort the in-flight connect attempt by - // closing the probe socket, then observe any pending - // exception so it is not unobserved. - try { probe.Close(); } catch { /* ignore */ } + // 延迟获胜;通过关闭探测套接字终止正在进行的连接尝试, + // 然后观察任何挂起的异常,以免其未被观察到。 + try { probe.Close(); } catch { /* 忽略 */ } try { await connectTask.ConfigureAwait(false); } - catch (SocketException) { /* not ready yet */ } - catch (IOException) { /* not ready yet */ } - catch (ObjectDisposedException) { /* connect aborted by closing probe */ } + catch (SocketException) { /* 尚未就绪 */ } + catch (IOException) { /* 尚未就绪 */ } + catch (ObjectDisposedException) { /* 关闭探测套接字导致连接中止 */ } } } await Task.Delay(interval).ConfigureAwait(false); @@ -621,7 +618,7 @@ public async Task WaitForServerAsync( } // --------------------------------------------------------------- - // Core command methods (sync wrappers) + // 核心命令方法(同步包装) // --------------------------------------------------------------- public CommandResponse SendAndWait(string command, TimeSpan? timeout = null) @@ -648,11 +645,11 @@ public void UnsubscribeStatus(string statusName, string moduleName, TimeSpan? ti => UnsubscribeStatusAsync(statusName, moduleName, timeout).GetAwaiter().GetResult(); // --------------------------------------------------------------- - // Core command methods (async) + // 核心命令方法(异步) // --------------------------------------------------------------- /// - /// Send a synchronous command (suffix -@) and wait for the response. + /// 发送同步命令(后缀 -@)并等待响应。 /// public async Task SendAndWaitAsync(string command, TimeSpan? timeout = null) { @@ -674,18 +671,18 @@ public async Task SendAndWaitAsync(string command, TimeSpan? ti } /// - /// Send an async command (suffix ->) and wait for the cmd-resp handshake. + /// 发送异步命令(后缀 ->)并等待 cmd-resp 握手。 /// public Task PostAsync(string command, TimeSpan? timeout = null) => SendAndAwaitCmdRespAsync(command, timeout); /// - /// Send an async no-reply command (suffix ->|) and wait for the cmd-resp handshake. + /// 发送异步无回复命令(后缀 ->|)并等待 cmd-resp 握手。 /// public Task PostNoReplyAsync(string command, TimeSpan? timeout = null) => SendAndAwaitCmdRespAsync(command, timeout); - /// Send a Ping and measure round-trip latency. + /// 发送 Ping 并测量往返延迟。 public async Task<(bool Ok, TimeSpan Elapsed)> PingAsync(TimeSpan? timeout = null) { var to = timeout ?? TimeSpan.FromSeconds(2); @@ -713,7 +710,7 @@ public Task ListStatesAsync(string module, TimeSpan? timeout = null) public Task HelpAsync(string module, TimeSpan? timeout = null) => SendAndWaitAsync($"Help {module}", timeout).ContinueWithText(); - /// Subscribe to a CSM module's status broadcast. + /// 订阅 CSM 模块的状态广播。 public async Task SubscribeStatusAsync( string statusName, string moduleName, StatusCallback callback = null, TimeSpan? timeout = null) { @@ -721,8 +718,8 @@ public async Task SubscribeStatusAsync( if (moduleName == null) throw new ArgumentNullException(nameof(moduleName)); var key = (statusName, moduleName); - // Register the callback *before* sending to eliminate the race - // where a STATUS packet could arrive before the callback is stored. + // 在发送之前注册回调,以消除状态数据包在回调 + // 存储之前到达的竞争条件。 lock (_stateLock) { _statusCallbacks[key] = callback; } string cmd = $"{statusName}@{moduleName} ->"; @@ -737,7 +734,7 @@ public async Task SubscribeStatusAsync( } } - /// Cancel a status subscription. + /// 取消状态订阅。 public async Task UnsubscribeStatusAsync(string statusName, string moduleName, TimeSpan? timeout = null) { if (statusName == null) throw new ArgumentNullException(nameof(statusName)); @@ -749,8 +746,8 @@ public async Task UnsubscribeStatusAsync(string statusName, string moduleName, T } /// - /// Register a callback for async-resp packets, matched by the original - /// command echoed in the async-resp payload (after the <- separator). + /// 为异步响应数据包注册回调,通过异步响应有效载荷中回显的原始命令 + /// (在 <- 分隔符之后)进行匹配。 /// public void RegisterAsyncCallback(string originalCommand, AsyncResponseCallback callback) { @@ -759,7 +756,7 @@ public void RegisterAsyncCallback(string originalCommand, AsyncResponseCallback lock (_stateLock) { _asyncCallbacks[originalCommand] = callback; } } - /// Remove a previously registered async callback. + /// 移除之前注册的异步回调。 public void UnregisterAsyncCallback(string originalCommand) { if (originalCommand == null) return; @@ -778,7 +775,7 @@ public void Dispose() } // --------------------------------------------------------------- - // Internal helpers + // 内部辅助方法 // --------------------------------------------------------------- private async Task SendAndAwaitCmdRespAsync(string command, TimeSpan? timeout) @@ -806,11 +803,10 @@ private async Task WaitForRespAsync(TimeSpan timeout) var winner = await Task.WhenAny(tcs.Task, Task.Delay(timeout)).ConfigureAwait(false); if (winner != tcs.Task) { - // Protocol v0 has no correlation id, so a late RESP for the - // timed-out command could be misattributed to the *next* - // SendAndWait call. Force a disconnect so the connection - // is unusable until the caller reconnects. - try { _transport.Disconnect(); } catch { /* ignore */ } + // 协议 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); @@ -825,15 +821,15 @@ private async Task WaitForCmdRespAsync(TimeSpan timeout) var winner = await Task.WhenAny(tcs.Task, Task.Delay(timeout)).ConfigureAwait(false); if (winner != tcs.Task) { - // Same desync risk as WaitForRespAsync: a late CMD_RESP could - // complete the next in-flight waiter. Force a disconnect so - // the connection cannot be reused after a handshake timeout. - try { _transport.Disconnect(); } catch { /* ignore */ } + // 与 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 payload is a handshake acknowledgment; discard it. + // CMD_RESP 有效载荷是握手确认;丢弃它。 } private void UnblockWaiters(Exception sentinel) @@ -843,7 +839,7 @@ private void UnblockWaiters(Exception sentinel) } // --------------------------------------------------------------- - // Internal: packet dispatch (runs on the receive task thread) + // 内部:数据包分发(在接收任务线程上运行) // --------------------------------------------------------------- internal void OnPacket(Packet packet) @@ -866,7 +862,7 @@ internal void OnPacket(Packet packet) lock (_stateLock) { _asyncCallbacks.TryGetValue(resp.OriginalCommand, out cb); } if (cb != null) { - try { cb(resp); } catch { /* swallow callback errors */ } + try { cb(resp); } catch { /* 吞掉回调错误 */ } } break; } @@ -880,7 +876,7 @@ internal void OnPacket(Packet packet) lock (_stateLock) { _statusCallbacks.TryGetValue((notif.StatusName, notif.ModuleName), out cb); } if (cb != null) { - try { cb(notif); } catch { /* swallow callback errors */ } + try { cb(notif); } catch { /* 吞掉回调错误 */ } } break; } @@ -894,11 +890,11 @@ internal void OnPacket(Packet packet) } case PacketType.Info: - // Silently discarded (welcome / goodbye messages). + // 静默丢弃(欢迎/再见消息)。 break; case PacketType.Cmd: - // Server should never send CMD; ignore for forward compatibility. + // 服务器永远不应发送 CMD;为向前兼容性忽略。 break; } } @@ -910,7 +906,7 @@ internal void OnDisconnect() } // ----------------------------------------------------------------------- - // Small convenience extensions + // 小型便利扩展 // ----------------------------------------------------------------------- internal static class TaskExtensions diff --git a/SDK/csharp/tests/CsmTcpRouter.Tests/ClientIntegrationTests.cs b/SDK/csharp/tests/CsmTcpRouter.Tests/ClientIntegrationTests.cs index ba7dbad..31f531f 100644 --- a/SDK/csharp/tests/CsmTcpRouter.Tests/ClientIntegrationTests.cs +++ b/SDK/csharp/tests/CsmTcpRouter.Tests/ClientIntegrationTests.cs @@ -10,18 +10,17 @@ namespace CsmTcpRouter.Tests { /// - /// End-to-end client tests against a real loopback . - /// Mirrors SDK/python/tests/test_integration.py + portions of test_client.py. + /// 针对真实回环 的端到端客户端测试。 + /// 镜像 SDK/python/tests/test_integration.py 以及 test_client.py 的部分内容。 /// public class ClientIntegrationTests { private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(2); /// - /// Bind a TcpListener to port 0 (OS-assigned), grab the port, then stop - /// the listener. The port is then almost certainly closed for the - /// duration of the test, so we can rely on connect attempts to fail - /// without depending on system state (e.g. port 1 may be open). + /// 将 TcpListener 绑定到端口 0(由操作系统分配),获取端口号,然后停止 + /// 监听器。在测试期间该端口几乎可以确定是关闭的,因此我们可以依赖连接 + /// 尝试失败,而无需依赖系统状态(例如,端口 1 可能是开放的)。 /// private static int GetClosedPort() { @@ -33,7 +32,7 @@ private static int GetClosedPort() } // --------------------------------------------------------------- - // Connect / Disconnect + // 连接 / 断开连接 // --------------------------------------------------------------- [Fact] @@ -80,7 +79,7 @@ public void Connect_BadPort_Throws() } // --------------------------------------------------------------- - // SendAndWait / built-ins + // SendAndWait / 内置方法 // --------------------------------------------------------------- [Fact] @@ -150,8 +149,8 @@ public void SendAndWait_Timeout_Throws() server.Start(); using var client = new TcpRouterClient(); client.Connect(server.Host, server.Port, DefaultTimeout); - // Server replies with CmdResp by default for unknown commands, not Resp, - // so a SendAndWait will time out waiting for a Resp. + // 服务器默认对未知命令回复 CmdResp,而不是 Resp, + // 因此 SendAndWait 在等待 Resp 时会超时。 Assert.Throws( () => client.SendAndWait("Unknown XYZ", TimeSpan.FromMilliseconds(200))); } @@ -165,7 +164,7 @@ public void SendAndWait_NotConnected_Throws() } // --------------------------------------------------------------- - // Post (async cmd-resp handshake) + // Post(异步 cmd-resp 握手) // --------------------------------------------------------------- [Fact] @@ -176,7 +175,7 @@ public void Post_CompletesOnHandshake() using var client = new TcpRouterClient(); client.Connect(server.Host, server.Port, DefaultTimeout); client.Post("API: Start -> DAQmx", DefaultTimeout); - // Make sure the server actually saw the command. + // 确保服务器实际收到了命令。 string cmd = server.GetReceived(DefaultTimeout); Assert.Equal("API: Start -> DAQmx", cmd); } @@ -194,7 +193,7 @@ public void PostNoReply_CompletesOnHandshake() } // --------------------------------------------------------------- - // Subscribe / status broadcast + // 订阅 / 状态广播 // --------------------------------------------------------------- [Fact] @@ -261,7 +260,7 @@ public void RegisterAsyncCallback_DeliversAsyncResponse() } // --------------------------------------------------------------- - // Disconnect-while-waiting unblocks waiters + // 等待期间断开连接会解除等待者的阻塞 // --------------------------------------------------------------- [Fact] @@ -272,7 +271,7 @@ public void Disconnect_WhileWaiting_RaisesConnectionError() using var client = new TcpRouterClient(); client.Connect(server.Host, server.Port, DefaultTimeout); - // Issue a SendAndWait whose response never comes; disconnect while waiting. + // 发起一个永远不会收到响应的 SendAndWait;在等待期间断开连接。 var task = Task.Run(() => client.SendAndWait("Unknown XYZ", TimeSpan.FromSeconds(10))); Thread.Sleep(100); client.Disconnect(); @@ -304,7 +303,7 @@ public void WaitForServer_ReturnsFalseOnTimeout() } // --------------------------------------------------------------- - // Async API + // 异步 API // --------------------------------------------------------------- [Fact] diff --git a/SDK/csharp/tests/CsmTcpRouter.Tests/MockServer.cs b/SDK/csharp/tests/CsmTcpRouter.Tests/MockServer.cs index 7472167..40779ce 100644 --- a/SDK/csharp/tests/CsmTcpRouter.Tests/MockServer.cs +++ b/SDK/csharp/tests/CsmTcpRouter.Tests/MockServer.cs @@ -11,8 +11,8 @@ namespace CsmTcpRouter.Tests { - /// Minimal TCP server that emulates a CSM-TCP-Router for tests. - /// Mirrors the Python tests/conftest.py MockServer. + /// 用于测试的最小化 TCP 服务器,模拟 CSM-TCP-Router。 + /// 镜像 Python tests/conftest.py MockServer。 internal sealed class MockServer : IDisposable { private TcpListener _listener; @@ -75,7 +75,7 @@ public void PushStatus(string payload) foreach (var c in snapshot) { try { c.GetStream().Write(wire, 0, wire.Length); } - catch { /* ignore */ } + catch { /* 忽略 */ } } } @@ -87,7 +87,7 @@ public void PushAsyncResponse(string payload) foreach (var c in snapshot) { try { c.GetStream().Write(wire, 0, wire.Length); } - catch { /* ignore */ } + catch { /* 忽略 */ } } } @@ -99,7 +99,7 @@ public string GetReceived(TimeSpan? timeout = null) } // ----------------------------------------------------------------- - // Internal + // 内部方法 // ----------------------------------------------------------------- private async Task AcceptLoopAsync(CancellationToken ct) @@ -116,7 +116,7 @@ private async Task AcceptLoopAsync(CancellationToken ct) _ = Task.Run(() => HandleClientAsync(client, ct)); } } - catch { /* ignore */ } + catch { /* 忽略 */ } } private async Task HandleClientAsync(TcpClient client, CancellationToken ct) @@ -124,7 +124,7 @@ private async Task HandleClientAsync(TcpClient client, CancellationToken ct) try { var stream = client.GetStream(); - // Welcome info packet + // 欢迎信息数据包 var welcome = ProtocolCodec.EncodePacket(Encoding.UTF8.GetBytes("Welcome to mock server"), PacketType.Info); await stream.WriteAsync(welcome, 0, welcome.Length, ct).ConfigureAwait(false); @@ -193,7 +193,7 @@ private void HandleCommand(NetworkStream stream, string cmd) } else { - // Generic async handshake for any other command. + // 对其他任何命令进行通用异步握手。 reply = ProtocolCodec.EncodePacket(Array.Empty(), PacketType.CmdResp); } diff --git a/SDK/csharp/tests/CsmTcpRouter.Tests/ProtocolTests.cs b/SDK/csharp/tests/CsmTcpRouter.Tests/ProtocolTests.cs index 14d8e65..9dbfdb8 100644 --- a/SDK/csharp/tests/CsmTcpRouter.Tests/ProtocolTests.cs +++ b/SDK/csharp/tests/CsmTcpRouter.Tests/ProtocolTests.cs @@ -5,7 +5,7 @@ namespace CsmTcpRouter.Tests { - // Mirrors SDK/python/tests/test_protocol.py. + // 镜像 SDK/python/tests/test_protocol.py。 public class ProtocolTests { private const int HeaderSize = 8; @@ -173,7 +173,7 @@ public void Parse_AllKnownTypes() [Fact] public void Parse_UnknownTypeMappedToInfo() { - // Manually craft a packet with an unknown type byte (0xFF). + // 手动构造一个具有未知类型字节 (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); @@ -262,7 +262,7 @@ public void ParseServerError_MalformedBracketNoCrash() } // ------------------------------------------------------------------- - // Model parsing helpers + // 模型解析辅助方法 // ------------------------------------------------------------------- [Fact] diff --git a/SDK/python/src/csm_tcp_router_client.py b/SDK/python/src/csm_tcp_router_client.py index 4058fab..f359f3a 100644 --- a/SDK/python/src/csm_tcp_router_client.py +++ b/SDK/python/src/csm_tcp_router_client.py @@ -1,10 +1,9 @@ -"""csm-tcp-router-client – single-file Python client SDK for the CSM-TCP-Router server. +"""csm-tcp-router-client – CSM-TCP-Router 服务器的单文件 Python 客户端 SDK。 -This module bundles the entire client implementation (sync and async) along -with the wire-protocol codec, exception hierarchy and public data models -into a single importable file. +本模块将完整的客户端实现(同步与异步)、线路协议编解码器、 +异常层次结构以及公共数据模型打包到一个可直接导入的文件中。 -Sync usage:: +同步用法:: from csm_tcp_router_client import TcpRouterClient @@ -12,7 +11,7 @@ client.connect("localhost", 30007) print(client.list_modules()) -Async usage:: +异步用法:: import asyncio from csm_tcp_router_client import AsyncTcpRouterClient @@ -24,12 +23,12 @@ async def main(): asyncio.run(main()) -Wire format (8-byte header, big-endian):: +线路格式(8 字节报头,大端序):: | Data Length (4B) | Version (1B=0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | ╰────────────────────────── Header (8B) ──────────────────────────╯ -followed by exactly ``Data Length`` bytes of payload. +后跟恰好 ``Data Length`` 字节的有效载荷。 """ from __future__ import annotations @@ -46,22 +45,22 @@ async def main(): from typing import Any, Callable, Coroutine, Dict, Optional, Tuple, Union __all__ = [ - # Clients + # 客户端 "TcpRouterClient", "AsyncTcpRouterClient", - # Exceptions + # 异常 "TcpRouterError", "ConnectionError", "TimeoutError", "ProtocolError", "ServerError", - # Models + # 数据模型 "PacketType", "Packet", "CommandResponse", "AsyncResponse", "StatusNotification", - # Version + # 版本 "__version__", ] @@ -69,33 +68,32 @@ async def main(): # =========================================================================== -# Exceptions +# 异常 # =========================================================================== class TcpRouterError(Exception): - """Base exception for all CSM-TCP-Router client errors.""" + """所有 CSM-TCP-Router 客户端错误的基异常。""" class ConnectionError(TcpRouterError): - """Raised when a connection cannot be established or is lost.""" + """当连接无法建立或已断开时抛出。""" class TimeoutError(TcpRouterError): - """Raised when a synchronous operation exceeds its timeout.""" + """当同步操作超过其超时时间时抛出。""" class ProtocolError(TcpRouterError): - """Raised when an invalid or unexpected protocol frame is received.""" + """当接收到无效或意外的协议帧时抛出。""" class ServerError(TcpRouterError): - """Raised when the server returns an error packet. + """当服务器返回错误数据包时抛出。 Attributes: - message: Human-readable error text from the server. - code: Optional error code extracted from the CSM Error format - ``[Error: ] ``. + message: 来自服务器的可读错误文本。 + code: 从 CSM 错误格式 ``[Error: ] `` 中提取的可选错误代码。 """ def __init__(self, message: str, code: str = "") -> None: @@ -109,30 +107,29 @@ def __str__(self) -> str: return self.message -# Internal aliases used by the transport / receive code below to avoid -# ambiguity with the module-level shadowed builtins. +# 传输/接收代码中使用的内部别名,用于避免与模块级遮蔽的内置名称产生歧义。 _RouterConnectionError = ConnectionError _RouterTimeoutError = TimeoutError # =========================================================================== -# Public data models +# 公共数据模型 # =========================================================================== class PacketType(IntEnum): - """Packet type constants as defined in the CSM-TCP-Router protocol v0. + """CSM-TCP-Router 协议 v0 中定义的数据包类型常量。 - Wire values + 线路值 ----------- - ``INFO`` 0x00 – informational messages (welcome / goodbye) - ``ERROR`` 0x01 – error messages from the server - ``CMD`` 0x02 – command sent by the client - ``CMD_RESP`` 0x03 – server handshake for async / no-reply / subscribe - ``RESP`` 0x04 – synchronous response payload - ``ASYNC_RESP`` 0x05 – asynchronous response payload - ``STATUS`` 0x06 – status broadcast from a subscribed CSM module - ``INTERRUPT`` 0x07 – interrupt broadcast from a subscribed CSM module + ``INFO`` 0x00 – 信息消息(欢迎/再见) + ``ERROR`` 0x01 – 来自服务器的错误消息 + ``CMD`` 0x02 – 客户端发送的命令 + ``CMD_RESP`` 0x03 – 服务器对异步/无回复/订阅的握手确认 + ``RESP`` 0x04 – 同步响应有效载荷 + ``ASYNC_RESP`` 0x05 – 异步响应有效载荷 + ``STATUS`` 0x06 – 来自已订阅 CSM 模块的状态广播 + ``INTERRUPT`` 0x07 – 来自已订阅 CSM 模块的中断广播 """ INFO = 0x00 @@ -147,7 +144,7 @@ class PacketType(IntEnum): @dataclass(frozen=True) class Packet: - """A decoded packet received from the server (internal representation).""" + """从服务器接收到的已解码数据包(内部表示)。""" type: PacketType data: bytes @@ -158,13 +155,13 @@ class Packet: @dataclass(frozen=True) class CommandResponse: - """The result of a synchronous command (:meth:`TcpRouterClient.send_and_wait`).""" + """同步命令(:meth:`TcpRouterClient.send_and_wait`)的结果。""" raw: bytes @property def text(self) -> str: - """Decoded UTF-8 text of the response payload.""" + """响应有效载荷的 UTF-8 解码文本。""" return self.raw.decode("utf-8", errors="replace") def __repr__(self) -> str: @@ -173,12 +170,12 @@ def __repr__(self) -> str: @dataclass(frozen=True) class AsyncResponse: - """An asynchronous response payload delivered via an ``async-resp`` packet. + """通过 ``async-resp`` 数据包传递的异步响应有效载荷。 Attributes: - raw: Raw response bytes (the part *before* the `` <- `` separator). - original_command: The original command text echoed back by the server - (the part *after* the `` <- `` separator). + raw: 原始响应字节(`` <- `` 分隔符*之前*的部分)。 + original_command: 服务器回显的原始命令文本 + (`` <- `` 分隔符*之后*的部分)。 """ raw: bytes @@ -186,14 +183,14 @@ class AsyncResponse: @property def text(self) -> str: - """Decoded UTF-8 text of the response payload.""" + """响应有效载荷的 UTF-8 解码文本。""" return self.raw.decode("utf-8", errors="replace") @classmethod def from_packet(cls, packet: Packet) -> AsyncResponse: - """Parse an ``ASYNC_RESP`` packet. + """解析一个 ``ASYNC_RESP`` 数据包。 - Server format: ``" <- "``. + 服务器格式:``" <- "``。 """ text = packet.data.decode("utf-8", errors="replace") parts = text.split(" <- ", 1) @@ -207,15 +204,15 @@ def __repr__(self) -> str: @dataclass(frozen=True) class StatusNotification: - """A status broadcast delivered via a ``status`` or ``interrupt`` packet. + """通过 ``status`` 或 ``interrupt`` 数据包传递的状态广播。 Attributes: - raw: Full raw payload bytes. - packet_type: Either :attr:`PacketType.STATUS` or - :attr:`PacketType.INTERRUPT`. - status_name: The name of the broadcasted status (left of ``>>``). - data: The status payload (between ``>>`` and ``<-``). - module_name: The sending CSM module name (right of ``<-``). + raw: 完整的原始有效载荷字节。 + packet_type: :attr:`PacketType.STATUS` 或 + :attr:`PacketType.INTERRUPT` 之一。 + status_name: 广播的状态名称(``>>`` 左侧)。 + data: 状态有效载荷(``>>`` 与 ``<-`` 之间)。 + module_name: 发送该状态的 CSM 模块名称(``<-`` 右侧)。 """ raw: bytes @@ -226,9 +223,9 @@ class StatusNotification: @classmethod def from_packet(cls, packet: Packet) -> StatusNotification: - """Parse a ``STATUS`` or ``INTERRUPT`` packet. + """解析一个 ``STATUS`` 或 ``INTERRUPT`` 数据包。 - Server format: ``" >> <- "``. + 服务器格式:``" >> <- "``。 """ text = packet.data.decode("utf-8", errors="replace") module = "" @@ -258,16 +255,16 @@ def __repr__(self) -> str: # =========================================================================== -# Protocol codec (internal but importable for advanced use / testing) +# 协议编解码器(内部使用,但可导入供高级用途/测试) # =========================================================================== -# Header layout: big-endian uint32 data_len + 4 x uint8 (version, type, flag1, flag2) +# 报头布局:大端序 uint32 data_len + 4 x uint8(version, type, flag1, flag2) _HEADER_FORMAT = "!IBBBB" -#: Number of bytes in the fixed packet header. +#: 固定数据包报头的字节数。 HEADER_SIZE: int = struct.calcsize(_HEADER_FORMAT) # == 8 -#: Protocol version byte sent in every outgoing packet. +#: 每个出站数据包中发送的协议版本字节。 PROTOCOL_VERSION: int = 0x01 @@ -277,13 +274,13 @@ def encode_packet( flag1: int = 0, flag2: int = 0, ) -> bytes: - """Encode *data* into a complete wire-format packet (header + body). + """将 *data* 编码为完整的线路格式数据包(报头 + 正文)。 - :param data: Raw payload bytes. - :param packet_type: :class:`PacketType` for the header. - :param flag1: FLAG1 byte (currently unused; defaults to 0). - :param flag2: FLAG2 byte (currently unused; defaults to 0). - :returns: Concatenated header + payload bytes ready for ``sendall()``. + :param data: 原始有效载荷字节。 + :param packet_type: 报头中使用的 :class:`PacketType`。 + :param flag1: FLAG1 字节(当前未使用;默认为 0)。 + :param flag2: FLAG2 字节(当前未使用;默认为 0)。 + :returns: 已拼接的报头 + 有效载荷字节,可直接传递给 ``sendall()``。 """ header = struct.pack( _HEADER_FORMAT, @@ -297,10 +294,10 @@ def encode_packet( def decode_header(header_bytes: bytes) -> Tuple[int, int, int, int, int]: - """Decode an 8-byte header into its constituent fields. + """将 8 字节报头解码为其各组成字段。 :returns: ``(data_len, version, type_byte, flag1, flag2)`` - :raises ProtocolError: if *header_bytes* is not exactly :data:`HEADER_SIZE` bytes. + :raises ProtocolError: 若 *header_bytes* 不恰好为 :data:`HEADER_SIZE` 字节。 """ if len(header_bytes) != HEADER_SIZE: raise ProtocolError( @@ -310,13 +307,12 @@ def decode_header(header_bytes: bytes) -> Tuple[int, int, int, int, int]: def parse_packet(header_bytes: bytes, body: bytes) -> Packet: - """Build a :class:`Packet` from raw header + body. + """从原始报头 + 正文构建 :class:`Packet`。 - Unknown packet type bytes are mapped to :attr:`PacketType.INFO` for - forward compatibility (the server may introduce new types in future - protocol revisions). + 未知的数据包类型字节将映射到 :attr:`PacketType.INFO` 以保持 + 前向兼容性(服务器在未来的协议修订中可能引入新类型)。 - :raises ProtocolError: on header size mismatch or body length mismatch. + :raises ProtocolError: 当报头大小不匹配或正文长度不匹配时。 """ data_len, version, type_byte, flag1, flag2 = decode_header(header_bytes) if len(body) != data_len: @@ -327,18 +323,18 @@ def parse_packet(header_bytes: bytes, body: bytes) -> Packet: try: ptype = PacketType(type_byte) except ValueError: - # Forward-compatible: treat unknown type as INFO + # 前向兼容:将未知类型视为 INFO ptype = PacketType.INFO return Packet(type=ptype, data=body, version=version, flag1=flag1, flag2=flag2) # =========================================================================== -# Shared server-error parsing helper +# 共享的服务器错误解析辅助函数 # =========================================================================== def _parse_server_error(packet: Packet) -> ServerError: - """Extract code and message from a CSM Error format ``[Error: ] ``.""" + """从 CSM 错误格式 ``[Error: ] `` 中提取错误代码和消息。""" text = packet.data.decode("utf-8", errors="replace").strip() code = "" msg = text @@ -353,16 +349,15 @@ def _parse_server_error(packet: Packet) -> ServerError: # =========================================================================== -# Internal: thread-based TCP transport (used by the sync client) +# 内部:基于线程的 TCP 传输(由同步客户端使用) # =========================================================================== class _Transport: - """Thread-safe, blocking TCP transport. + """线程安全的阻塞式 TCP 传输。 - A background daemon thread continuously reads packets from the socket and - dispatches them via *on_packet*. Callers are responsible for keeping - callbacks fast and non-blocking, as they run in the receive thread. + 后台守护线程持续从套接字读取数据包,并通过 *on_packet* 进行分发。 + 调用方负责保持回调函数快速且无阻塞,因为它们在接收线程中运行。 """ def __init__( @@ -379,11 +374,11 @@ def __init__( @property def connected(self) -> bool: - """``True`` while the socket is open and the stop event has not fired.""" + """``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: - """Open a TCP connection and start the receive thread.""" + """建立 TCP 连接并启动接收线程。""" if self.connected: raise _RouterConnectionError( "Already connected; call disconnect() first." @@ -393,7 +388,7 @@ def connect(self, host: str, port: int, timeout: float = 5.0) -> None: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) sock.connect((host, port)) - sock.settimeout(None) # switch to blocking for the recv loop + sock.settimeout(None) # 切换为阻塞模式以用于接收循环 except OSError as exc: if sock is not None: try: @@ -414,7 +409,7 @@ def connect(self, host: str, port: int, timeout: float = 5.0) -> None: self._recv_thread.start() def disconnect(self, join_timeout: float = 2.0) -> None: - """Close the connection and stop the receive thread.""" + """关闭连接并停止接收线程。""" self._stop_event.set() if self._sock is not None: try: @@ -430,7 +425,7 @@ def disconnect(self, join_timeout: float = 2.0) -> None: self._recv_thread.join(timeout=join_timeout) def send_raw(self, data: bytes) -> None: - """Send *data* atomically. Thread-safe.""" + """原子性地发送 *data*。线程安全。""" if not self.connected: raise _RouterConnectionError("Not connected.") with self._send_lock: @@ -441,12 +436,12 @@ def send_raw(self, data: bytes) -> None: raise _RouterConnectionError(f"Send failed: {exc}") from exc def _recv_all(self, size: int) -> bytes: - """Read exactly *size* bytes; returns empty bytes on clean EOF or disconnect.""" + """精确读取 *size* 字节;在干净的 EOF 或断开连接时返回空字节。""" buf = bytearray(size) view = memoryview(buf) received = 0 while received < size: - sock = self._sock # capture locally to avoid TOCTOU race with disconnect() + sock = self._sock # 本地捕获,避免与 disconnect() 产生 TOCTOU 竞争 if sock is None: return b"" try: @@ -459,14 +454,14 @@ def _recv_all(self, size: int) -> bytes: return bytes(buf) def _recv_loop(self) -> None: - """Background thread: read packets and dispatch via callback.""" + """后台线程:读取数据包并通过回调进行分发。""" try: while not self._stop_event.is_set(): header = self._recv_all(HEADER_SIZE) if not header: break - # Extract data_len from the first 4 bytes without full decode + # 从前 4 个字节提取 data_len,无需完整解码 (data_len,) = struct.unpack("!I", header[:4]) body = self._recv_all(data_len) if len(body) != data_len: @@ -475,7 +470,7 @@ def _recv_loop(self) -> None: try: packet = parse_packet(header, body) except ProtocolError: - # Corrupted frame – skip it and keep the loop alive + # 帧损坏 – 跳过并保持循环运行 continue self._on_packet(packet) @@ -489,29 +484,27 @@ def _recv_loop(self) -> None: # =========================================================================== -# TcpRouterClient – thread-based synchronous client +# TcpRouterClient – 基于线程的同步客户端 # =========================================================================== -# Type aliases +# 类型别名 _SubKey = Tuple[str, str] StatusCallback = Callable[[StatusNotification], None] AsyncCallback = Callable[[AsyncResponse], None] -# Items held in the internal queues are either Packet or Exception instances. +# 内部队列中存放的元素为 Packet 或 Exception 实例。 _QueueItem = object class TcpRouterClient: - """Python client for a CSM-TCP-Router server. + """CSM-TCP-Router 服务器的同步客户端。 - This class mirrors the LabVIEW ClientAPI VIs and speaks the - CSM-TCP-Router protocol v0. It is thread-safe in that its internal - state is protected by locks; however, the protocol allows at most one - in-flight *synchronous* command at a time and at most one in-flight - *async* command / subscription at a time. Concurrent callers are - serialised by ``_resp_lock`` and ``_cmd_resp_lock`` respectively. + 本类镜像了 LabVIEW ClientAPI VI,并实现了 CSM-TCP-Router 协议 v0。 + 其内部状态通过锁保护,因此是线程安全的;但协议同时只允许一个在途 + *同步* 命令和一个在途 *异步* 命令/订阅。并发调用者分别由 + ``_resp_lock`` 和 ``_cmd_resp_lock`` 串行化。 - **Quickstart**:: + **快速入门**:: from csm_tcp_router_client import TcpRouterClient @@ -519,33 +512,29 @@ class TcpRouterClient: client.connect("localhost", 30007) print(client.list_modules()) - **Protocol flows**: - - - *Synchronous* command (``-@``): :meth:`send_and_wait` – sends a ``CMD`` - packet and blocks until a ``RESP`` (or ``ERROR``) is received. - - *Asynchronous* command (``->``): :meth:`post` – sends a ``CMD`` packet - and blocks until the ``CMD_RESP`` handshake is received; the eventual - ``ASYNC_RESP`` is delivered asynchronously. - - *No-reply async* command (``->|``): :meth:`post_no_reply` – same as - :meth:`post` but no ``ASYNC_RESP`` will ever arrive. - - *Subscribe / unsubscribe*: :meth:`subscribe_status` / - :meth:`unsubscribe_status` – sends a ```` / ```` - command and waits for the ``CMD_RESP`` handshake. - - **Received-packet routing** (on the background receive thread): - - - ``RESP`` (0x04) – unblocks the caller of :meth:`send_and_wait`. - - ``CMD_RESP`` (0x03) – unblocks callers of :meth:`post`, - :meth:`post_no_reply`, :meth:`subscribe_status`, and - :meth:`unsubscribe_status`. - - ``ASYNC_RESP`` (0x05) – added to :attr:`async_response_queue` and - dispatched to any matching :meth:`register_async_callback`. - - ``STATUS`` / ``INTERRUPT`` (0x06 / 0x07) – added to - :attr:`status_queue` and dispatched to any matching - :meth:`subscribe_status` callback. - - ``ERROR`` (0x01) – unblocks any pending synchronous waiter with a - :exc:`ServerError`. - - ``INFO`` (0x00) – silently discarded (welcome / goodbye messages). + **协议流程**: + + - *同步* 命令 (``-@``)::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: @@ -554,26 +543,24 @@ def __init__(self) -> None: on_disconnect=self._on_disconnect, ) - # One-item-deep queues for synchronised waits. - # Items are either Packet or Exception instances. + # 用于同步等待的单项队列。 + # 队列元素为 Packet 或 Exception 实例。 self._resp_queue: queue.Queue[_QueueItem] = queue.Queue() self._cmd_resp_queue: queue.Queue[_QueueItem] = queue.Queue() - #: Polling queue for :class:`AsyncResponse` objects received from the server. + #: 用于轮询从服务器收到的 :class:`AsyncResponse` 对象的队列。 self.async_response_queue: queue.Queue[AsyncResponse] = queue.Queue() - #: Polling queue for :class:`StatusNotification` objects received - #: from the server. + #: 用于轮询从服务器收到的 :class:`StatusNotification` 对象的队列。 self.status_queue: queue.Queue[StatusNotification] = queue.Queue() - # Callback registries (protected by _lock) + # 回调注册表(由 _lock 保护) self._status_callbacks: Dict[_SubKey, Optional[StatusCallback]] = {} self._async_callbacks: Dict[str, AsyncCallback] = {} self._lock = threading.Lock() - # Serialisation locks – at most one in-flight RESP / CMD_RESP waiter - # at a time. This prevents concurrent callers from consuming each - # other's response packets. + # 串行化锁 – 同时最多只有一个在途的 RESP / CMD_RESP 等待者, + # 防止并发调用者消费彼此的响应包。 self._resp_lock = threading.Lock() self._cmd_resp_lock = threading.Lock() From 0c4fb0802227983c9a0a4ee43237262dc74847df Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:16:42 +0800 Subject: [PATCH 10/10] Add interactive ClientConsole example to Python, C# and C SDKs (#43) * Add interactive ClientConsole example to Python, C# and C SDKs Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/ca3628f9-8283-448f-8789-d860c873c1dc Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * Validate port argument in Python and C# ClientConsole examples Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/1a565ac6-daf6-4291-8c0c-b3444c37d8ba Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --- SDK/c/CMakeLists.txt | 2 + SDK/c/examples/client_console.c | 265 ++++++++++++++++++ SDK/csharp/CsmTcpRouter.sln | 15 + .../ClientConsole/ClientConsole.csproj | 17 ++ SDK/csharp/examples/ClientConsole/Program.cs | 236 ++++++++++++++++ SDK/python/examples/client_console.py | 222 +++++++++++++++ 6 files changed, 757 insertions(+) create mode 100644 SDK/c/examples/client_console.c create mode 100644 SDK/csharp/examples/ClientConsole/ClientConsole.csproj create mode 100644 SDK/csharp/examples/ClientConsole/Program.cs create mode 100644 SDK/python/examples/client_console.py diff --git a/SDK/c/CMakeLists.txt b/SDK/c/CMakeLists.txt index 1c42a1f..73033bb 100644 --- a/SDK/c/CMakeLists.txt +++ b/SDK/c/CMakeLists.txt @@ -86,8 +86,10 @@ endif() 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() # ------------------------------------------------------------------------- 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/csharp/CsmTcpRouter.sln b/SDK/csharp/CsmTcpRouter.sln index 337b5e9..0f53351 100644 --- a/SDK/csharp/CsmTcpRouter.sln +++ b/SDK/csharp/CsmTcpRouter.sln @@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{B3 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 @@ -61,6 +63,18 @@ Global {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 @@ -69,5 +83,6 @@ Global {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/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/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))