From e43c5176bf439bade34327931e5de851ea11ddb4 Mon Sep 17 00:00:00 2001 From: pengqi Date: Sun, 24 Aug 2025 23:45:24 +0800 Subject: [PATCH] =?UTF-8?q?V3.0=20=E5=A2=9E=E5=8A=A0=E9=80=9A=E7=94=A8?= =?UTF-8?q?=E8=AF=BB=E5=86=99=E6=8E=A5=E5=8F=A3=EF=BC=8C=E5=BE=85=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=A8=B3=E5=AE=9A=E6=80=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 3 + .idea/gateway_V3_20250818.iml | 12 + .idea/inspectionProfiles/Project_Default.xml | 6 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 4 + .idea/modules.xml | 8 + config/config.json | 52 + config/config.json.bak | 52 + .../__pycache__/api_server.cpython-310.pyc | Bin 0 -> 57458 bytes .../__pycache__/api_server.cpython-313.pyc | Bin 0 -> 70769 bytes .../api_server_html.cpython-313.pyc | Bin 0 -> 19288 bytes .../__pycache__/cache_manager.cpython-310.pyc | Bin 0 -> 11236 bytes .../__pycache__/cache_manager.cpython-313.pyc | Bin 0 -> 36543 bytes .../__pycache__/config_loader.cpython-310.pyc | Bin 0 -> 521 bytes .../__pycache__/config_loader.cpython-313.pyc | Bin 0 -> 749 bytes .../config_manager.cpython-310.pyc | Bin 0 -> 2952 bytes .../config_manager.cpython-313.pyc | Bin 0 -> 5456 bytes .../config_validator.cpython-310.pyc | Bin 0 -> 1876 bytes .../config_validator.cpython-313.pyc | Bin 0 -> 2658 bytes .../__pycache__/plc_manager.cpython-310.pyc | Bin 0 -> 1537 bytes .../__pycache__/plc_manager.cpython-313.pyc | Bin 0 -> 2097 bytes .../__pycache__/snap7_client.cpython-310.pyc | Bin 0 -> 5888 bytes .../__pycache__/snap7_client.cpython-313.pyc | Bin 0 -> 18331 bytes gateway/api_server.py | 1602 +++++++++++++++++ gateway/api_server_html.py | 446 +++++ gateway/cache_manager.py | 877 +++++++++ gateway/config_loader.py | 10 + gateway/config_manager.py | 87 + gateway/config_validator.py | 91 + gateway/data_parser.py | 55 + gateway/main.py | 109 ++ gateway/plc_manager.py | 42 + gateway/snap7_client.py | 448 +++++ gateway/templates/api_doc.html | 670 +++++++ gateway/templates/config.html | 209 +++ gateway/templates/status.html | 126 ++ 36 files changed, 4915 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/gateway_V3_20250818.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 config/config.json create mode 100644 config/config.json.bak create mode 100644 gateway/__pycache__/api_server.cpython-310.pyc create mode 100644 gateway/__pycache__/api_server.cpython-313.pyc create mode 100644 gateway/__pycache__/api_server_html.cpython-313.pyc create mode 100644 gateway/__pycache__/cache_manager.cpython-310.pyc create mode 100644 gateway/__pycache__/cache_manager.cpython-313.pyc create mode 100644 gateway/__pycache__/config_loader.cpython-310.pyc create mode 100644 gateway/__pycache__/config_loader.cpython-313.pyc create mode 100644 gateway/__pycache__/config_manager.cpython-310.pyc create mode 100644 gateway/__pycache__/config_manager.cpython-313.pyc create mode 100644 gateway/__pycache__/config_validator.cpython-310.pyc create mode 100644 gateway/__pycache__/config_validator.cpython-313.pyc create mode 100644 gateway/__pycache__/plc_manager.cpython-310.pyc create mode 100644 gateway/__pycache__/plc_manager.cpython-313.pyc create mode 100644 gateway/__pycache__/snap7_client.cpython-310.pyc create mode 100644 gateway/__pycache__/snap7_client.cpython-313.pyc create mode 100644 gateway/api_server.py create mode 100644 gateway/api_server_html.py create mode 100644 gateway/cache_manager.py create mode 100644 gateway/config_loader.py create mode 100644 gateway/config_manager.py create mode 100644 gateway/config_validator.py create mode 100644 gateway/data_parser.py create mode 100644 gateway/main.py create mode 100644 gateway/plc_manager.py create mode 100644 gateway/snap7_client.py create mode 100644 gateway/templates/api_doc.html create mode 100644 gateway/templates/config.html create mode 100644 gateway/templates/status.html diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..359bb53 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml diff --git a/.idea/gateway_V3_20250818.iml b/.idea/gateway_V3_20250818.iml new file mode 100644 index 0000000..026865d --- /dev/null +++ b/.idea/gateway_V3_20250818.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f6df581 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..fca8e89 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..b96f153 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..ef0ff59 --- /dev/null +++ b/config/config.json @@ -0,0 +1,52 @@ +{ + "plcs": [ + { + "name": "PLC1", + "ip": "192.168.0.1", + "rack": 0, + "slot": 1, + "refresh_interval": 0.5, + "areas": [ + { + "name": "DB100_Read", + "type": "read", + "db_number": 100, + "offset": 0, + "size": 6000, + "structure": [ + { + "name": "temperature", + "type": "real", + "offset": 0 + }, + { + "name": "pressure", + "type": "int", + "offset": 4 + }, + { + "name": "status", + "type": "bool", + "offset": 6, + "bit": 0 + } + ] + }, + { + "name": "DB100_Write", + "type": "write", + "db_number": 100, + "offset": 0, + "size": 6000 + }, + { + "name": "DB202_Params", + "type": "read_write", + "db_number": 202, + "offset": 0, + "size": 816 + } + ] + } + ] +} \ No newline at end of file diff --git a/config/config.json.bak b/config/config.json.bak new file mode 100644 index 0000000..630d729 --- /dev/null +++ b/config/config.json.bak @@ -0,0 +1,52 @@ +{ + "plcs": [ + { + "name": "PLC1", + "ip": "192.168.0.1", + "rack": 0, + "slot": 1, + "refresh_interval": 1, + "areas": [ + { + "name": "DB100_Read", + "type": "read", + "db_number": 100, + "offset": 0, + "size": 5000, + "structure": [ + { + "name": "temperature", + "type": "real", + "offset": 0 + }, + { + "name": "pressure", + "type": "int", + "offset": 4 + }, + { + "name": "status", + "type": "bool", + "offset": 6, + "bit": 0 + } + ] + }, + { + "name": "DB100_Write", + "type": "write", + "db_number": 100, + "offset": 0, + "size": 5000 + }, + { + "name": "DB202_Params", + "type": "read_write", + "db_number": 202, + "offset": 0, + "size": 816 + } + ] + } + ] +} \ No newline at end of file diff --git a/gateway/__pycache__/api_server.cpython-310.pyc b/gateway/__pycache__/api_server.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e7e66e7aa6d3921cc83689bc674db3084dc804d7 GIT binary patch literal 57458 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 AK>z>% literal 0 HcmV?d00001 diff --git a/gateway/__pycache__/api_server.cpython-313.pyc b/gateway/__pycache__/api_server.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fbb54fdd4042d30259a01db4cf8b60ac80d3fce4 GIT binary patch literal 70769 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 F{y#JveHj1% literal 0 HcmV?d00001 diff --git a/gateway/__pycache__/api_server_html.cpython-313.pyc b/gateway/__pycache__/api_server_html.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..345f0c37ea7f44b7c3f40642bcb7abe8c1563e9f GIT binary patch literal 19288 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 literal 0 HcmV?d00001 diff --git a/gateway/__pycache__/cache_manager.cpython-310.pyc b/gateway/__pycache__/cache_manager.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0f9813a90a66ddbf780b9ab7a26bcb8278130057 GIT binary patch literal 11236 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 L;d2UIlEeQ7elmKQ literal 0 HcmV?d00001 diff --git a/gateway/__pycache__/config_loader.cpython-310.pyc b/gateway/__pycache__/config_loader.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7ff5a864da4273895667c88ae6a8e6a45ec7d1ae GIT binary patch literal 521 zcmYjOOG_g`5U%QJXe3^YA_&Xg z_OdL* zumOdU!~%*j;R$|*1txG8!qk9bApbU3mPw_yzcdorx|T_qYJU+U%_q(g>mLLL>eyo) zxI!HmPhbcB!avr-{0R(%$ALrFL)e97&4H?sz^oY_6puQW}H~==CiyZBM~w?H5$4!%*D!oPyzMvwvt;d z?kHK0%ci)qohvrmrYe)-eq`dqsQo2BlU=P?Wt(j~<J*L4`2QUX}Ylf+JO|UM!fD8qEiGu_?TuNbasZk literal 0 HcmV?d00001 diff --git a/gateway/__pycache__/config_loader.cpython-313.pyc b/gateway/__pycache__/config_loader.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e2d28f596f3a627f940b71cf1cadb1a7c6505299 GIT binary patch literal 749 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 literal 0 HcmV?d00001 diff --git a/gateway/__pycache__/config_validator.cpython-313.pyc b/gateway/__pycache__/config_validator.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3346fa3ff3c55ee2b18d56dcee1c14053411ffbd GIT binary patch literal 2658 zcmZ7&TTmO<_3l#-#7hE$F-F1=>~%q42ke^ICIp87v9ZmKlD5#l)_JdX zJ+FK3d9AiW;>%VUN{IN<2YkJ!|JPv%1c|4rC6z+o3?H}7cgp0E$Mfalh*2=e_PZ-d z$&!_82C(_sZ|;Bj@%O*`^}WwO+x+PD2cP_WKwhelP2mi(w3gk31Nw@3SD4iq;e@L zr*o-!og{wN^zk$Qc;(fR1Sr8Oj3|QRh6G?oT`@hb<&qW@hDdZRMx{k!Ky7&0Ms?__ zXHf@_j;Z3J;(R8P1h@pc0^Ea&F$aPfuop7~JjEE=bipx?9+}c!W;awsDyFrRUU^g| z9yfK13iAeBhID~d`I_Ck_SBmtnXFDMLpQ0L=vQ(E(PP#7NpSC!smQyw)DxRm%V8kQ z4HZq}6^JXuITy8ZL|3F;WumYnnHo9z(2;J4=OlAXjxcW7JLQcm#7m351TK>qi#8BF zPIPlYWho)hVJxw_W>ydAV{XF?^fPb_K$PXYx?~^(_-%g*;3n!ovpdq+N*g0I!zydf znq}d27Uuf7p^6pePw{Ck!p(T9(p?-uRjm+82%Tuw89PkwLRt$#X&#HSwN?EHU)3Ju zO+JXQl;%hI?h1}`adbuqqBy6>IrhAJU;gR-o42R%#c3Q}&{JBu>?@71`pL%1`}hCw z{>IAkgAZPP@cJ)F1DGfrtt%I_3MOpuDExENtGBRl@Kt7><<*3t$)KeL7Fwi*NWXhpXh>n~ap1VXcto6(}XuZA!jKD>0X|94* z;8p3lvL|)NQ8>Fq>nl55t(+6w)A->%3Zrnc83Aw&6=jd7^Iq%W^@iX#4L$jWp7mhw z?fC5<{Uvy!BnkWLmM4pDRJ-pRUq{~8v3m8dz8*Vp@Zan8t=ow6blQQ|x4Txg-}kJX z`I|5F-=YA#|Cnqd|Kjlz=riwVi|{q_jrxVJeH^3x62M2wiv`jtuRB28L=iNzQ(nNR zEWA@bX>OKjJ1ero#r*IhLK;{mga{Wv6OXfEiSRQlDSK9RZnB<2A3LO@HyZj z+!=+N{{!+l7%!5UO&Ty#l6o43r5Q64hDF*zsECAhB$cunu}m1nfT#eI!eo(xatV=P zCa*EfauKM|?+5ng`yt^)(B8>4Y7E?R-*B%6-l$!c)@$mPbJvGg8?TQ*9=~hazkJRP zwA?y<Q&WLCYDPw+NY{la$2$?saMswK};)Yr1f+L#4c5hWumG|*xv{0GcCj--iKy}YPPzzxLyw{Afw?9q%dC2KoZEK3N|W|BrxAnwjmB??SQXB-uEX-r=5mPR_aK{?6A#i;FD+ z>(pfH${(DNKT()p3JB+6C$9sMMACrtsYNOBEMOLUpGZaWZ6f&=vlPiL5mS8vw#1}) z`%-8R&aMW&8>VXxew<$ng!8bIGeBHoQAsQ&DLRqLHnF(mQiW5Mn$+Rcq#+mJ)MX1E zY3Hqr7gj&8L)&xX;Tzxr#Dm|zn%@0(HW|qwRjRlX^hodGu51bIZxc+velk@(`tcGzaL|+z9VrPj(^@-UFltnqjlFwdjP99*hsHLVb8Nu_qx5& zoOrumRq6C#BR7QbLq8Ql2Ominh^8CVpp0teuk-Y%x_KP@)s;PcX#ZTzFcOTdtXT&b ztiwLd=$Jz0>F_o9ba3}^@!9?9?yX|7SKPZZd-&6AZyOvJldq>ck7qypXtEgVo-2?D z;BgMh0L$z3Ke2RZh!8RlpBvn{=9H86fBfy*R@gys z3f@RC>Mmr_Pn~1lFo{??<7i?6F&tmw<5ArJQlfY3C_dH*wky z+wg@K*GvN&8b?ovyd^|GlEVP`ju6*|c2H|{A!OtT5icT~BS^6G7& y+7pxv!l}WGV+QZ2rN-n|C&7PKQ5PJWGRA=_)i3gx8|Nx)Z)2C19nc{L?XqWH-;ksL literal 0 HcmV?d00001 diff --git a/gateway/__pycache__/plc_manager.cpython-313.pyc b/gateway/__pycache__/plc_manager.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ecf9db727748eb9f33989d75b9261f1779807676 GIT binary patch literal 2097 zcmb7F+fN)-7(a8{8_PvjmWo_fC?%tUZBnWsMq^4;S}qOaCMLy9)@4|yF0*)MHim~L zm}uP+R4WPQ;1FNjSFp4y~|E|({P)%fc7otY&-dC-&WcfRwT-eWZE<=r$6Z;Tq$^q;!#j$$q=D}MyRL>RMJE$<438Y$sm22 z0@APfKn65F4WCy>V6mjLh~@)qBHCb< zyjjM%(?i6`pbOk1<5C-}``QU9BQbKeL^K)n?Sb(H5Ug~1Y{9#BuqmJF&%O+hP$BnO z;nrkf>N0q@<9I0a3QgHvp%{#@bkf$78EeE$b;SU*@zM0!kBf!emGzqoAtqaiQG~bjG3RM8B#q84pwRhWXQ(RLZncESNH!UeFE7d}uSMD#I}&J;KU%-Niz% z@b0An_=1g(#|N>l?yjLTG;_{KI`$Bt+EWR~ct0^dZAqi(1>#>QDd*Gif{qhlS9Z5 z{)#Vvn1u{FDDHz^RsdvY#D!9H4@1RdpMs6`i+2h$msh7gE-cO$E={l9`gU#pL-4h* z`1xw?&f3@Cs3Hvmj!6&*(5paq3C66B#b^XK{jiBVvg%zldA36Uuxmg}kcX9#neqJi z%=!HJt8d(`Z2mdWOrx-qELAAz+%xXoIavH3?>g}`45%TvE(`A)tB#9}MsiY5H_oG5YOb{@`JOI#Yn^4T}QdO*(rE7se-p$Q$*o7KBJPh5bU6SIu~ z^m2}K4155=UMKzJyVZ#gfYBg;SkU9g7-nVEaOgOcch+)Lg~x#+WgJMGQA6@nna6_p zY$xGlZJwqSFWhMcZbWJ@Grg~y8B2HJ4BJ(y#1BMZz1XjTfG9l{$@x|u3Dv`B{T1_5 zbETnWxuJcjp?#&Pb-C%-Qq!?5zg%CDdv&sR3q!aWCRNc~IgcdNR3&_pJJqj-u;pNr zgq9Q1_0i05HjR2%*Wb$~(%w#`u8)}1cG9M0SQ*&%>-umesq6G{{L~XDnozWYU^P1T z&`jzH$D!uiS;w$-{VT$+=Zx?U%ms$s#`71*uk!J2xl%c?-7G3C+W|>w_&XF-qV6e7 zIcUUT8~<~4H|=@nrsc5Xeh>a~cHO}OFS_2mM4y8Vta#oPHYGt29+1QLNyR_%5h1!w IK=4cc1*9L2_jgv>NUotw`_k3u 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 literal 0 HcmV?d00001 diff --git a/gateway/__pycache__/snap7_client.cpython-313.pyc b/gateway/__pycache__/snap7_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b8090c5d630013a765be71c2f95a09140caa5ed8 GIT binary patch literal 18331 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 JLLnuE{11_$=(hj> literal 0 HcmV?d00001 diff --git a/gateway/api_server.py b/gateway/api_server.py new file mode 100644 index 0000000..43d8bb6 --- /dev/null +++ b/gateway/api_server.py @@ -0,0 +1,1602 @@ +from flask import Flask, jsonify, request, render_template_string, Response +import threading +import time +import json +from functools import wraps +from config_manager import ConfigManager +import logging + +class APIServer: + """REST API服务器,提供PLC数据访问和配置管理功能""" + + def __init__(self, cache_manager, config_path="./config/config.json"): + """ + 初始化API服务器 + + Args: + cache_manager: 缓存管理器实例 + config_path: 配置文件路径 + """ + self.cache_manager = cache_manager + self.config_manager = ConfigManager(config_path) + self.app = Flask(__name__) + self.logger = logging.getLogger("APIServer") + self.auth_enabled = True # 可通过配置关闭认证 + self.username = "admin" + self.password = "admin123" # 实际应用中应从安全存储获取 + self.start_time = time.strftime("%Y-%m-%d %H:%M:%S") + + # 在初始化方法中调用 setup_routes + self.setup_routes() + + def check_auth(self, username, password): + """验证用户名和密码""" + return username == self.username and password == self.password + + def authenticate(self): + """发送401响应要求认证""" + return Response( + "Unauthorized", + 401, + {"WWW-Authenticate": 'Basic realm="PLC Gateway Configuration"'} + ) + + def requires_auth(self, f): + """装饰器:需要认证的路由,保留函数元数据""" + @wraps(f) + def decorated(*args, **kwargs): + if not self.auth_enabled: + return f(*args, **kwargs) + + auth = request.authorization + if not auth or not self.check_auth(auth.username, auth.password): + return self.authenticate() + return f(*args, **kwargs) + return decorated + + def get_summary(self): + """获取缓存摘要信息""" + summary = {} + for plc_name, areas in self.cache_manager.cache.items(): + summary[plc_name] = {} + for area_name, area in areas.items(): + last_update = self.cache_manager.last_update[plc_name][area_name] + plc_status = self.cache_manager.plc_connection_status.get(plc_name, "unknown") + + summary[plc_name][area_name] = { + "status": area["status"], + "plc_connection_status": plc_status, + "last_update": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(last_update)) if last_update > 0 else "Never", + "size": area["size"], + "type": area["type"] + } + return summary + + def setup_routes(self): + """设置所有API路由""" + + # =========================== + # 主页面 - 状态摘要 + # =========================== + @self.app.route("/", endpoint="index") + def index(): + summary = self.get_summary() + html = """ + + + PLC Gateway Status + + + +

PLC Gateway Status

+

Gateway running since: {{ start_time }}

+ """ + + for plc_name, areas in summary.items(): + plc_status = self.cache_manager.plc_connection_status.get(plc_name, "unknown") + plc_class = "" + if plc_status == "connected": + plc_class = "plc-connected" + elif plc_status == "disconnected": + plc_class = "plc-disconnected" + else: + plc_class = "plc-never-connected" + + html += f'

PLC: {plc_name} (Status: {plc_status})

' + html += """ + + + + + + + + + + """ + + for area_name, area in areas.items(): + status_class = "" + status_text = area["status"] + + if area["status"] == "connected": + status_class = "status-connected" + elif area["status"] == "never_connected": + status_class = "status-never-connected" + status_text = "Never connected" + elif area["status"] == "disconnected": + status_class = "status-disconnected" + status_text = "Disconnected" + else: + status_class = "status-disconnected" + + html += f""" + + + + + + + + + """ + + html += "
Area NameTypeSize (bytes)StatusPLC ConnectionLast Update
{area_name}{area['type']}{area['size']}{status_text}{area['plc_connection_status']}{area['last_update']}
" + + # 添加API文档部分 + html += """ +
+

API Endpoints

+ +
+ Single Read: GET /api/read////
+ Example: /api/read/PLC1/DB100_Read/10/4 +
+ +
+ Single Write: POST /api/write///
+ Body: Raw binary data
+ Example: POST /api/write/PLC1/DB100_Write/10 with 4 bytes of data +
+ +
+ Single Read_Bool: GET /api/read_bool////
+ Example: /api/read_bool/PLC1/DB100_Read/0/2 +
+ +
+ Single Write_Bool: POST /api/write_bool///
+ Body: Raw binary data
+ Example: POST /api/write_bool/PLC1/DB100_Write/0 +
+ +
+ Batch Read: POST /api/batch_read
+ Body: JSON array of read requests
+ Example: [{"plc_name":"PLC1", "area_name":"DB100_Read", "offset":0, "length":4}] +
+ +
+ Batch Write: POST /api/batch_write
+ Body: JSON array of write requests
+ Example: [{"plc_name":"PLC1", "area_name":"DB100_Write", "offset":0, "data":[1,2,3,4]}] +
+ +
+ Configuration: GET/POST /api/config
+ Manage gateway configuration +
+
+ +
+ + + + + """ + + html += """ + + + """ + return render_template_string(html, start_time=self.start_time) + + # =========================== + # 系统状态API + # =========================== + @self.app.route("/api/status", endpoint="system_status") + def system_status(): + """获取系统状态信息""" + plc_statuses = {} + for plc_name in self.cache_manager.plc_connection_status: + plc_statuses[plc_name] = { + "status": self.cache_manager.plc_connection_status[plc_name], + "last_connected": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.cache_manager.plc_last_connected[plc_name])) + if self.cache_manager.plc_last_connected[plc_name] > 0 else "Never" + } + + return jsonify({ + "status": "running", + "start_time": self.start_time, + "plc_count": len(self.config_manager.get_config().get("plcs", [])), + "cache_size": sum(len(area["data"]) for plc in self.cache_manager.cache.values() for area in plc.values()), + "plc_statuses": plc_statuses + }) + + # =========================== + # 配置管理相关路由 + # =========================== + @self.app.route("/config", endpoint="config_page") + @self.requires_auth + def config_page(): + """配置编辑页面""" + config = self.config_manager.get_config() + config_json = json.dumps(config, indent=2) + + html = """ + + + PLC Gateway Configuration + + + +

PLC Gateway Configuration

+ + + +
+

Edit the configuration JSON below. Be careful with the syntax.

+ +
+ +
+ + + +
+
+ +
+ +
+

Configuration Guide

+

PLC Configuration:

+
    +
  • name: Unique name for the PLC
  • +
  • ip: IP address of the PLC
  • +
  • rack: Rack number (usually 0)
  • +
  • slot: Slot number (usually 1 for S7-1200)
  • +
+ +

Data Area Configuration:

+
    +
  • name: Name of the data area
  • +
  • type: read, write, or read_write
  • +
  • db_number: DB number (e.g., 100 for DB100)
  • +
  • offset: Starting byte offset
  • +
  • size: Size in bytes
  • +
  • structure (optional): Define how to parse the data
  • +
+ +

Example:

+
{
+  "plcs": [
+    {
+      "name": "PLC1",
+      "ip": "192.168.0.10",
+      "rack": 0,
+      "slot": 1,
+      "areas": [
+        {
+          "name": "DB100_Read",
+          "type": "read",
+          "db_number": 100,
+          "offset": 0,
+          "size": 4000,
+          "structure": [
+            {"name": "temperature", "type": "real", "offset": 0},
+            {"name": "pressure", "type": "int", "offset": 4}
+          ]
+        }
+      ]
+    }
+  ]
+}
+
+
+ + + + + """ + return render_template_string( + html, + config_json=config_json, + username=self.username, + password=self.password + ) + + # 配置验证API + @self.app.route("/api/config/validate", methods=["POST"], endpoint="validate_config") + @self.requires_auth + def validate_config(): + """验证配置是否有效""" + try: + config = request.json + is_valid, error = self.config_manager.validate_config(config) + if is_valid: + return jsonify({"valid": True}) + else: + return jsonify({"valid": False, "message": error}), 400 + except Exception as e: + return jsonify({"valid": False, "message": str(e)}), 400 + + # 配置获取API + @self.app.route("/api/config", methods=["GET"], endpoint="get_config") + @self.requires_auth + def get_config(): + """获取当前配置""" + return jsonify(self.config_manager.get_config()) + + # 配置保存API + @self.app.route("/api/config", methods=["POST"], endpoint="save_config") + @self.requires_auth + def save_config(): + """保存配置""" + try: + config = request.json + reload = request.args.get('reload', 'false').lower() == 'true' + + success, message = self.config_manager.save_config(config) + if success: + if reload: + # 通知主应用程序重载配置 + if hasattr(self.cache_manager, 'app') and self.cache_manager.app: + self.cache_manager.app.request_reload() + return jsonify({ + "success": True, + "message": "Configuration saved and reload requested" + }) + else: + return jsonify({ + "success": True, + "message": "Configuration saved successfully (restart to apply changes)" + }) + else: + return jsonify({ + "success": False, + "message": message + }), 400 + except Exception as e: + return jsonify({ + "success": False, + "message": f"Error saving config: {str(e)}" + }), 500 + + # =========================== + # 新增 API 文档接口 + # =========================== + @self.app.route("/api/doc", endpoint="api_doc") + def api_doc(): + """API文档页面""" + html = """ + + + PLC Gateway API Documentation + + + +

PLC Gateway API Documentation

+ + + +

Status API

+ +
+

System Status

+
+ GET + /api/status +
+

获取系统状态信息,包括启动时间、PLC数量和缓存大小。

+ +

响应示例

+
+ { + "status": "running", + "start_time": "2023-10-30 14:30:22", + "plc_count": 2, + "cache_size": 11000, + "plc_statuses": { + "PLC1": { + "status": "connected", + "last_connected": "2023-10-30 14:35:10" + }, + "PLC2": { + "status": "disconnected", + "last_connected": "Never" + } + } + } +
+
+ +
+

Area Status

+
+ GET + /api/status// +
+

获取指定PLC区域的状态信息。

+ +

路径参数

+ + + + + + + + + + + + + +
参数描述
plc_namePLC名称(如PLC1)
area_name区域名称(如DB100_Read)
+ +

响应示例

+
+ { + "status": "connected", + "plc_connection_status": "connected", + "last_update": 1698754321.456, + "last_update_formatted": "2023-10-30 14:12:01", + "size": 4000, + "type": "read" + } +
+
+ +

Data API

+ +
+

Single Read

+
+ GET + /api/read//// +
+

从指定区域读取数据。

+ +

路径参数

+ + + + + + + + + + + + + + + + + + + + + +
参数描述
plc_namePLC名称(如PLC1)
area_name区域名称(如DB100_Read)
offset起始偏移量(字节)
length读取长度(字节)
+ +

响应示例

+
+ { + "status": "success", + "plc_name": "PLC1", + "area_name": "DB100_Read", + "offset": 0, + "length": 4, + "data": [0, 0, 123, 45], + "plc_connection_status": "connected", + "last_update": 1698754321.456, + "last_update_formatted": "2023-10-30 14:12:01" + } +
+
+ +
+

Single Write

+
+ POST + /api/write/// +
+

向指定区域写入数据。

+ +

路径参数

+ + + + + + + + + + + + + + + + + +
参数描述
plc_namePLC名称(如PLC1)
area_name区域名称(如DB100_Write)
offset起始偏移量(字节)
+ +

请求体

+

原始二进制数据

+ +

响应示例

+
+ { + "status": "success", + "plc_name": "PLC1", + "area_name": "DB100_Write", + "offset": 0, + "length": 4, + "plc_connection_status": "connected", + "last_update": 1698754350.789, + "last_update_formatted": "2023-10-30 14:12:30" + } +
+
+ +
+

Single Read Bool

+
+ GET + /api/read_bool//// +
+

从指定区域读取数据。

+ +

路径参数

+ + + + + + + + + + + + + + + + + + + + + +
参数描述
plc_namePLC名称(如PLC1)
area_name区域名称(如DB100_Read)
offset起始偏移量(字节)
length读取长度(字节)
+ +

响应示例

+
+ { + "status": "success", + "plc_name": "PLC1", + "area_name": "DB100_Read", + "offset": 0, + "length": 2, + "data": [0:False, 1:False], + "plc_connection_status": "connected", + "last_update": 1698754321.456, + "last_update_formatted": "2023-10-30 14:12:01" + } +
+
+ +
+

Single Write Bool

+
+ POST + /api/write_bool/// +
+

向指定区域写入数据。

+ +

路径参数

+ + + + + + + + + + + + + + + + + +
参数描述
plc_namePLC名称(如PLC1)
area_name区域名称(如DB100_Write)
offset起始偏移量(字节)
+ +

请求体

+

{0:True}

+ +

响应示例

+
+ { + "status": "success", + "plc_name": "PLC1", + "area_name": "DB100_Write", + "offset": 0, + "length": 1, + "plc_connection_status": "connected", + "last_update": 1698754350.789, + "last_update_formatted": "2023-10-30 14:12:30" + } +
+
+ +
+

Batch Read

+
+ POST + /api/batch_read +
+

批量读取多个区域的数据。

+ +

请求体

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必需描述
plc_namestringPLC名称(与配置中一致)
area_namestring区域名称(与配置中一致)
offsetnumber起始偏移量(字节),默认为0
lengthnumber读取长度(字节),不提供则读取整个区域
+ +

请求示例

+
+ [ + { + "plc_name": "PLC1", + "area_name": "DB100_Read", + "offset": 0, + "length": 4 + }, + { + "plc_name": "PLC1", + "area_name": "DB202_Params", + "offset": 10, + "length": 2 + } + ] +
+ +

响应示例

+
+ [ + { + "plc_name": "PLC1", + "area_name": "DB100_Read", + "status": "success", + "plc_connection_status": "connected", + "last_update": 1698754321.456, + "last_update_formatted": "2023-10-30 14:12:01", + "offset": 0, + "length": 4, + "data": [0, 0, 123, 45] + }, + { + "plc_name": "PLC1", + "area_name": "DB202_Params", + "status": "success", + "plc_connection_status": "connected", + "last_update": 1698754322.123, + "last_update_formatted": "2023-10-30 14:12:02", + "offset": 10, + "length": 2, + "data": [255, 0] + } + ] +
+
+ +
+

Batch Write

+
+ POST + /api/batch_write +
+

批量写入多个区域的数据。

+ +

请求体

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必需描述
plc_namestringPLC名称(与配置中一致)
area_namestring区域名称(与配置中一致)
offsetnumber起始偏移量(字节)
dataarray要写入的数据(字节数组)
+ +

请求示例

+
+ [ + { + "plc_name": "PLC1", + "area_name": "DB100_Write", + "offset": 0, + "data": [1, 2, 3, 4] + }, + { + "plc_name": "PLC1", + "area_name": "DB202_Params", + "offset": 10, + "data": [255, 0] + } + ] +
+ +

响应示例

+
+ [ + { + "plc_name": "PLC1", + "area_name": "DB100_Write", + "status": "success", + "plc_connection_status": "connected", + "last_update": 1698754350.789, + "last_update_formatted": "2023-10-30 14:12:30", + "offset": 0, + "length": 4 + }, + { + "plc_name": "PLC1", + "area_name": "DB202_Params", + "status": "success", + "plc_connection_status": "connected", + "last_update": 1698754351.234, + "last_update_formatted": "2023-10-30 14:12:31", + "offset": 10, + "length": 2 + } + ] +
+
+ +
+

Parsed Data

+
+ GET + /api/data// +
+

获取解析后的数据(如果配置了结构)。

+ +

路径参数

+ + + + + + + + + + + + + +
参数描述
plc_namePLC名称(如PLC1)
area_name区域名称(如DB100_Read)
+ +

响应示例(配置了解析结构)

+
+ { + "parsed": { + "temperature": 25.5, + "pressure": 100, + "status": true + }, + "raw_data": [0, 0, 128, 65, 0, 100], + "status": "connected", + "plc_connection_status": "connected", + "last_update": 1698754321.456, + "last_update_formatted": "2023-10-30 14:12:01" + } +
+ +

响应示例(未配置解析结构)

+
+ { + "raw_data": [0, 0, 128, 65, 0, 100], + "status": "connected", + "plc_connection_status": "connected", + "last_update": 1698754321.456, + "last_update_formatted": "2023-10-30 14:12:01" + } +
+
+ +

Configuration API

+ +
+

Get Configuration

+
+ GET + /api/config +
+

获取当前配置。

+ +

认证要求

+

需要Basic Auth认证

+ +

响应示例

+
+ { + "plcs": [ + { + "name": "PLC1", + "ip": "192.168.0.10", + "rack": 0, + "slot": 1, + "areas": [ + { + "name": "DB100_Read", + "type": "read", + "db_number": 100, + "offset": 0, + "size": 4000, + "structure": [ + {"name": "temperature", "type": "real", "offset": 0}, + {"name": "pressure", "type": "int", "offset": 4} + ] + } + ] + } + ] + } +
+
+ +
+

Validate Configuration

+
+ POST + /api/config/validate +
+

验证配置是否有效。

+ +

认证要求

+

需要Basic Auth认证

+ +

请求体

+

要验证的配置JSON

+ +

响应示例(有效)

+
+ { + "valid": true + } +
+ +

响应示例(无效)

+
+ { + "valid": false, + "message": "Invalid configuration: 'ip' is a required property" + } +
+
+ +
+

Save Configuration

+
+ POST + /api/config +
+

保存配置。

+ +

查询参数

+ + + + + + + + + +
参数描述
reload是否立即重载配置(true/false)
+ +

认证要求

+

需要Basic Auth认证

+ +

请求体

+

要保存的配置JSON

+ +

响应示例

+
+ { + "success": true, + "message": "Configuration saved and reload requested" + } +
+
+ + + + + """ + return render_template_string(html) + + # =========================== + # 数据访问API + # =========================== + # 单个读取接口 + @self.app.route("/api/read////", methods=["GET"], endpoint="single_read") + def single_read(plc_name, area_name, offset, length): + """从指定区域读取数据""" + data, error, plc_status, update_time = self.cache_manager.read_area(plc_name, area_name, offset, length) + if error: + return jsonify({ + "status": "error", + "plc_name": plc_name, + "area_name": area_name, + "message": error, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) if update_time > 0 else "Never" + }), 400 + return jsonify({ + "status": "success", + "plc_name": plc_name, + "area_name": area_name, + "offset": offset, + "length": length, + "data": list(data), + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) + }) + + # 单个读取BOOL类型接口 + @self.app.route("/api/read_bool////", methods=["GET"], endpoint="single_read_bool") + def single_read_bool(plc_name, area_name, offset, length): + """从指定区域读取数据""" + data, error, plc_status, update_time = self.cache_manager.read_area_bool(plc_name, area_name, offset, length) + if error: + return jsonify({ + "status": "error", + "plc_name": plc_name, + "area_name": area_name, + "message": error, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) if update_time > 0 else "Never" + }), 400 + return jsonify({ + "status": "success", + "plc_name": plc_name, + "area_name": area_name, + "offset": offset, + "length": length, + "data": [data], # list(data) + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) + }) + + # 单个写入接口 + @self.app.route("/api/write///", methods=["POST"], endpoint="single_write") + def single_write(plc_name, area_name, offset): + """向指定区域写入数据""" + data = request.data + if not data: + # 如果没有提供数据,返回错误 + return jsonify({ + "status": "error", + "message": "No data provided", + "plc_connection_status": self.cache_manager.plc_connection_status.get(plc_name, "unknown"), + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + success, error, plc_status, update_time = self.cache_manager.write_area(plc_name, area_name, offset, data) + if error: + return jsonify({ + "status": "error", + "plc_name": plc_name, + "area_name": area_name, + "message": error, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) if update_time > 0 else "Never" + }), 400 + return jsonify({ + "status": "success", + "plc_name": plc_name, + "area_name": area_name, + "offset": offset, + "length": len(data), + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) + }) + + # 单个写入BOOL类型接口 + @self.app.route("/api/write_bool///", methods=["POST"], endpoint="single_write_bool") + def single_write_bool(plc_name, area_name, offset): + """向指定区域写入数据""" + data = request.data + if not data: + # 如果没有提供数据,返回错误 + return jsonify({ + "status": "error", + "message": "No data provided", + "plc_connection_status": self.cache_manager.plc_connection_status.get(plc_name, "unknown"), + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + success, error, plc_status, update_time = self.cache_manager.write_area_bool(plc_name, area_name, offset, data) + if error: + return jsonify({ + "status": "error", + "plc_name": plc_name, + "area_name": area_name, + "message": error, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime(update_time)) if update_time > 0 else "Never" + }), 400 + return jsonify({ + "status": "success", + "plc_name": plc_name, + "area_name": area_name, + "offset": offset, + "length": 1, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) + }) + + # 批量读取接口 + @self.app.route("/api/batch_read", methods=["POST"], endpoint="batch_read") + def batch_read(): + """批量读取多个区域的数据""" + try: + # 确保是JSON请求 + if not request.is_json: + return jsonify({ + "status": "error", + "message": "Request must be JSON (Content-Type: application/json)", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + requests = request.get_json() + if not isinstance(requests, list): + return jsonify({ + "status": "error", + "message": "Request must be a JSON array", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + # 添加详细日志 + self.logger.info(f"Received batch read request: {json.dumps(requests)}") + + results = self.cache_manager.batch_read(requests) + + return jsonify(results) + except Exception as e: + self.logger.error(f"Batch read error: {str(e)}", exc_info=True) + return jsonify({ + "status": "error", + "message": f"Internal server error: {str(e)}", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 500 + + # 批量写入接口 + @self.app.route("/api/batch_write", methods=["POST"], endpoint="batch_write") + def batch_write(): + """批量写入多个区域的数据""" + try: + if not request.is_json: + return jsonify({ + "status": "error", + "message": "Request must be JSON (Content-Type: application/json)", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + requests = request.json + if not isinstance(requests, list): + return jsonify({ + "status": "error", + "message": "Request must be a JSON array", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + self.logger.info(f"Received batch write request: {json.dumps(requests)}") + + results = self.cache_manager.batch_write(requests) + return jsonify(results) + except Exception as e: + self.logger.error(f"Batch write error: {str(e)}", exc_info=True) + return jsonify({ + "status": "error", + "message": f"Internal server error: {str(e)}", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 500 + + # 通用读取接口 + @self.app.route("/api/read_generic////", methods=["GET"], + endpoint="read_generic") + def read_generic(plc_name, area_name, offset, data_type): + """通用读取接口""" + print("Enter Read generic") + # 检查请求参数 + count = request.args.get('count', 1, type=int) + if count < 1: + return jsonify({ + "status": "error", + "message": "Count must be at least 1", + "plc_name": plc_name, + "area_name": area_name + }), 400 + + # 执行读取 + result, error, plc_status, update_time = self.cache_manager.read_generic( + plc_name, + area_name, + offset, + data_type, + count + ) + + if error: + return jsonify({ + "status": "error", + "plc_name": plc_name, + "area_name": area_name, + "message": error, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime( + update_time)) if update_time > 0 else "Never" + }), 400 + + return jsonify({ + "status": "success", + "plc_name": plc_name, + "area_name": area_name, + "offset": offset, + "data_type": data_type, + "count": count, + "data": result, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) + }) + + # 通用写入接口 + @self.app.route("/api/write_generic////", methods=["POST"], + endpoint="write_generic") + def write_generic(plc_name, area_name, offset, data_type): + """通用写入接口""" + # 检查请求数据 + if not request.is_json: + return jsonify({ + "status": "error", + "message": "Request must be JSON (Content-Type: application/json)", + "plc_name": plc_name, + "area_name": area_name + }), 400 + json_data = request.get_json() + if "value" not in json_data and "values" not in json_data: + return jsonify({ + "status": "error", + "message": "Missing 'value' or 'values' field", + "plc_name": plc_name, + "area_name": area_name + }), 400 + + # 确定要写入的值 + value = json_data.get("value", json_data.get("values")) + + # 执行写入 + success, error, plc_status, update_time = self.cache_manager.write_generic( + plc_name, + area_name, + offset, + data_type, + value + ) + + if error: + return jsonify({ + "status": "error", + "plc_name": plc_name, + "area_name": area_name, + "message": error, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime( + update_time)) if update_time > 0 else "Never" + }), 400 + + # 确定写入数量 + count = 1 + if isinstance(value, list): + count = len(value) + + return jsonify({ + "status": "success", + "plc_name": plc_name, + "area_name": area_name, + "offset": offset, + "data_type": data_type, + "count": count, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) + }) + + @self.app.route("/api/batch_write_bool", methods=["POST"], endpoint="batch_write_bool") + def batch_write_bool(): + """批量写入多个区域的数据""" + try: + if not request.is_json: + return jsonify({ + "status": "error", + "message": "Request must be JSON (Content-Type: application/json)", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + requests = request.json + if not isinstance(requests, list): + return jsonify({ + "status": "error", + "message": "Request must be a JSON array", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + self.logger.info(f"Received batch write request: {json.dumps(requests)}") + + results = self.cache_manager.batch_write_bool(requests) + return jsonify(results) + except Exception as e: + self.logger.error(f"Batch write error: {str(e)}", exc_info=True) + return jsonify({ + "status": "error", + "message": f"Internal server error: {str(e)}", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 500 + + # 区域状态检查 + @self.app.route("/api/status//", methods=["GET"], endpoint="area_status") + def area_status(plc_name, area_name): + """获取区域状态""" + status = self.cache_manager.get_area_status(plc_name, area_name) + return jsonify(status) + + # 获取解析后的数据 + @self.app.route("/api/data//", methods=["GET"], endpoint="get_parsed_data") + def get_parsed_data(plc_name, area_name): + """获取解析后的数据""" + return jsonify(self.cache_manager.get_parsed_data(plc_name, area_name)) + + def start(self): + """启动API服务器""" + self.server_thread = threading.Thread( + target=self.app.run, + kwargs={ + "host": "0.0.0.0", + "port": 5000, + "threaded": True, + "use_reloader": False # 避免在生产环境中使用重载器 + }, + daemon=True, + name="APIServerThread" + ) + self.server_thread.start() + self.logger.info("API server started at http://0.0.0.0:5000") \ No newline at end of file diff --git a/gateway/api_server_html.py b/gateway/api_server_html.py new file mode 100644 index 0000000..5ff10f1 --- /dev/null +++ b/gateway/api_server_html.py @@ -0,0 +1,446 @@ +from flask import Flask, jsonify, request, render_template_string, Response, render_template +import threading +import time +import json +from functools import wraps +from config_manager import ConfigManager +import logging + + +class APIServer: + """REST API服务器,提供PLC数据访问和配置管理功能""" + + def __init__(self, cache_manager, config_path="config/config.json"): + """ + 初始化API服务器 + + Args: + cache_manager: 缓存管理器实例 + config_path: 配置文件路径 + """ + self.cache_manager = cache_manager + self.config_manager = ConfigManager(config_path) + self.app = Flask(__name__) + self.logger = logging.getLogger("APIServer") + self.auth_enabled = True # 可通过配置关闭认证 + self.username = "admin" + self.password = "admin123" # 实际应用中应从安全存储获取 + self.start_time = time.strftime("%Y-%m-%d %H:%M:%S") + + # 在初始化方法中调用 setup_routes + self.setup_routes() + + def check_auth(self, username, password): + """验证用户名和密码""" + return username == self.username and password == self.password + + def authenticate(self): + """发送401响应要求认证""" + return Response( + "Unauthorized", + 401, + {"WWW-Authenticate": 'Basic realm="PLC Gateway Configuration"'} + ) + + def requires_auth(self, f): + """装饰器:需要认证的路由,保留函数元数据""" + + @wraps(f) + def decorated(*args, **kwargs): + if not self.auth_enabled: + return f(*args, **kwargs) + + auth = request.authorization + if not auth or not self.check_auth(auth.username, auth.password): + return self.authenticate() + return f(*args, **kwargs) + + return decorated + + def get_summary(self): + """获取缓存摘要信息""" + summary = {} + for plc_name, areas in self.cache_manager.cache.items(): + summary[plc_name] = {} + for area_name, area in areas.items(): + last_update = self.cache_manager.last_update[plc_name][area_name] + plc_status = self.cache_manager.plc_connection_status.get(plc_name, "unknown") + + summary[plc_name][area_name] = { + "status": area["status"], + "plc_connection_status": plc_status, + "last_update": time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime(last_update)) if last_update > 0 else "Never", + "size": area["size"], + "type": area["type"] + } + return summary + + def setup_routes(self): + """设置所有API路由""" + + # =========================== + # 主页面 - 状态摘要 + # =========================== + @self.app.route("/", endpoint="index") + def index(): + summary = self.get_summary() + return render_template( + "status.html", # 模板文件名 + start_time=self.start_time, + summary=summary, + plc_statuses=self.cache_manager.plc_connection_status + ) + + # =========================== + # 系统状态API + # =========================== + @self.app.route("/api/status", endpoint="system_status") + def system_status(): + """获取系统状态信息""" + plc_statuses = {} + for plc_name in self.cache_manager.plc_connection_status: + plc_statuses[plc_name] = { + "status": self.cache_manager.plc_connection_status[plc_name], + "last_connected": time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime(self.cache_manager.plc_last_connected[plc_name])) + if self.cache_manager.plc_last_connected[plc_name] > 0 else "Never" + } + + return jsonify({ + "status": "running", + "start_time": self.start_time, + "plc_count": len(self.config_manager.get_config().get("plcs", [])), + "cache_size": sum( + len(area["data"]) for plc in self.cache_manager.cache.values() for area in plc.values()), + "plc_statuses": plc_statuses + }) + + # =========================== + # 配置管理相关路由 + # =========================== + @self.app.route("/config", endpoint="config_page") + @self.requires_auth + def config_page(): + """配置编辑页面""" + config = self.config_manager.get_config() + config_json = json.dumps(config, indent=2) + + return render_template( + 'config.html', + config_json=config_json, + username=self.username, + password=self.password + ) + + # 配置验证API + @self.app.route("/api/config/validate", methods=["POST"], endpoint="validate_config") + @self.requires_auth + def validate_config(): + """验证配置是否有效""" + try: + config = request.json + is_valid, error = self.config_manager.validate_config(config) + if is_valid: + return jsonify({"valid": True}) + else: + return jsonify({"valid": False, "message": error}), 400 + except Exception as e: + return jsonify({"valid": False, "message": str(e)}), 400 + + # 配置获取API + @self.app.route("/api/config", methods=["GET"], endpoint="get_config") + @self.requires_auth + def get_config(): + """获取当前配置""" + return jsonify(self.config_manager.get_config()) + + # 配置保存API + @self.app.route("/api/config", methods=["POST"], endpoint="save_config") + @self.requires_auth + def save_config(): + """保存配置""" + try: + config = request.json + reload = request.args.get('reload', 'false').lower() == 'true' + + success, message = self.config_manager.save_config(config) + if success: + if reload: + # 通知主应用程序重载配置 + if hasattr(self.cache_manager, 'app') and self.cache_manager.app: + self.cache_manager.app.request_reload() + return jsonify({ + "success": True, + "message": "Configuration saved and reload requested" + }) + else: + return jsonify({ + "success": True, + "message": "Configuration saved successfully (restart to apply changes)" + }) + else: + return jsonify({ + "success": False, + "message": message + }), 400 + except Exception as e: + return jsonify({ + "success": False, + "message": f"Error saving config: {str(e)}" + }), 500 + + # =========================== + # 新增 API 文档接口 + # =========================== + @self.app.route("/api/doc", endpoint="api_doc") + def api_doc(): + """API文档页面""" + return render_template('api_doc.html') + + # =========================== + # 数据访问API + # =========================== + # 单个读取接口 + @self.app.route("/api/read////", methods=["GET"], + endpoint="single_read") + def single_read(plc_name, area_name, offset, length): + """从指定区域读取数据""" + data, error, plc_status, update_time = self.cache_manager.read_area(plc_name, area_name, offset, length) + if error: + return jsonify({ + "status": "error", + "plc_name": plc_name, + "area_name": area_name, + "message": error, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime(update_time)) if update_time > 0 else "Never" + }), 400 + return jsonify({ + "status": "success", + "plc_name": plc_name, + "area_name": area_name, + "offset": offset, + "length": length, + "data": list(data), + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) + }) + + # 单个读取BOOL类型接口 + @self.app.route("/api/read_bool////", methods=["GET"], + endpoint="single_read_bool") + def single_read_bool(plc_name, area_name, offset, length): + """从指定区域读取数据""" + data, error, plc_status, update_time = self.cache_manager.read_area_bool(plc_name, area_name, offset, + length) + if error: + return jsonify({ + "status": "error", + "plc_name": plc_name, + "area_name": area_name, + "message": error, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime(update_time)) if update_time > 0 else "Never" + }), 400 + return jsonify({ + "status": "success", + "plc_name": plc_name, + "area_name": area_name, + "offset": offset, + "length": length, + "data": [data], # list(data) + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) + }) + + # 单个写入接口 + @self.app.route("/api/write///", methods=["POST"], endpoint="single_write") + def single_write(plc_name, area_name, offset): + """向指定区域写入数据""" + data = request.data + if not data: + # 如果没有提供数据,返回错误 + return jsonify({ + "status": "error", + "message": "No data provided", + "plc_connection_status": self.cache_manager.plc_connection_status.get(plc_name, "unknown"), + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + success, error, plc_status, update_time = self.cache_manager.write_area(plc_name, area_name, offset, data) + if error: + return jsonify({ + "status": "error", + "plc_name": plc_name, + "area_name": area_name, + "message": error, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime(update_time)) if update_time > 0 else "Never" + }), 400 + return jsonify({ + "status": "success", + "plc_name": plc_name, + "area_name": area_name, + "offset": offset, + "length": len(data), + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) + }) + + # 单个写入BOOL类型接口 + @self.app.route("/api/write_bool///", methods=["POST"], + endpoint="single_write_bool") + def single_write_bool(plc_name, area_name, offset): + """向指定区域写入数据""" + data = request.data + if not data: + # 如果没有提供数据,返回错误 + return jsonify({ + "status": "error", + "message": "No data provided", + "plc_connection_status": self.cache_manager.plc_connection_status.get(plc_name, "unknown"), + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + success, error, plc_status, update_time = self.cache_manager.write_area_bool(plc_name, area_name, offset, + data) + if error: + return jsonify({ + "status": "error", + "plc_name": plc_name, + "area_name": area_name, + "message": error, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime(update_time)) if update_time > 0 else "Never" + }), 400 + return jsonify({ + "status": "success", + "plc_name": plc_name, + "area_name": area_name, + "offset": offset, + "length": 1, + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) + }) + + # 批量读取接口 + @self.app.route("/api/batch_read", methods=["POST"], endpoint="batch_read") + def batch_read(): + """批量读取多个区域的数据""" + try: + # 确保是JSON请求 + if not request.is_json: + return jsonify({ + "status": "error", + "message": "Request must be JSON (Content-Type: application/json)", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + requests = request.json + if not isinstance(requests, list): + return jsonify({ + "status": "error", + "message": "Request must be a JSON array", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + # 添加详细日志 + self.logger.info(f"Received batch read request: {json.dumps(requests)}") + + results = self.cache_manager.batch_read(requests) + return jsonify(results) + except Exception as e: + self.logger.error(f"Batch read error: {str(e)}", exc_info=True) + return jsonify({ + "status": "error", + "message": f"Internal server error: {str(e)}", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 500 + + # 批量写入接口 + @self.app.route("/api/batch_write", methods=["POST"], endpoint="batch_write") + def batch_write(): + """批量写入多个区域的数据""" + try: + if not request.is_json: + return jsonify({ + "status": "error", + "message": "Request must be JSON (Content-Type: application/json)", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + requests = request.json + if not isinstance(requests, list): + return jsonify({ + "status": "error", + "message": "Request must be a JSON array", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 400 + + self.logger.info(f"Received batch write request: {json.dumps(requests)}") + + results = self.cache_manager.batch_write(requests) + return jsonify(results) + except Exception as e: + self.logger.error(f"Batch write error: {str(e)}", exc_info=True) + return jsonify({ + "status": "error", + "message": f"Internal server error: {str(e)}", + "plc_connection_status": "unknown", + "last_update": 0, + "last_update_formatted": "N/A" + }), 500 + + # 区域状态检查 + @self.app.route("/api/status//", methods=["GET"], endpoint="area_status") + def area_status(plc_name, area_name): + """获取区域状态""" + status = self.cache_manager.get_area_status(plc_name, area_name) + return jsonify(status) + + # 获取解析后的数据 + @self.app.route("/api/data//", methods=["GET"], endpoint="get_parsed_data") + def get_parsed_data(plc_name, area_name): + """获取解析后的数据""" + return jsonify(self.cache_manager.get_parsed_data(plc_name, area_name)) + + def start(self): + """启动API服务器""" + self.server_thread = threading.Thread( + target=self.app.run, + kwargs={ + "host": "0.0.0.0", + "port": 5000, + "threaded": True, + "use_reloader": False # 避免在生产环境中使用重载器 + }, + daemon=True, + name="APIServerThread" + ) + self.server_thread.start() + self.logger.info("API server started at http://0.0.0.0:5000") \ No newline at end of file diff --git a/gateway/cache_manager.py b/gateway/cache_manager.py new file mode 100644 index 0000000..fd6621d --- /dev/null +++ b/gateway/cache_manager.py @@ -0,0 +1,877 @@ +import threading +import time +import logging +from snap7.util import * +import struct + +class CacheManager: + """PLC数据缓存管理器""" + + def __init__(self, config, plc_manager, app=None): + """ + 初始化缓存管理器 + + Args: + config: 配置对象 + plc_manager: PLC管理器实例 + app: 主应用程序引用(用于配置重载) + """ + self.plc_manager = plc_manager + self.config = config + self.app = app + self.cache = {} + self.refresh_interval = 1 # 1秒刷新一次 + self.running = False + self.lock = threading.Lock() + self.thread = None + self.last_update = {} # 区域级最后更新时间 + self.plc_last_connected = {} # PLC级最后连接时间 + self.plc_connection_status = {} # PLC连接状态 + self.logger = logging.getLogger("CacheManager") + self.init_cache() + + def init_cache(self): + """初始化缓存结构""" + for plc in self.config["plcs"]: + plc_name = plc["name"] + self.cache[plc_name] = {} + self.last_update[plc_name] = {} + self.plc_last_connected[plc_name] = 0 # 初始化为0(未连接) + self.plc_connection_status[plc_name] = "never_connected" + + for area in plc["areas"]: + name = area["name"] + # 确保初始状态为断开 + self.cache[plc_name][name] = { + "data": bytearray(area["size"]), + "db_number": area["db_number"], + "offset": area["offset"], + "size": area["size"], + "type": area["type"], + "structure": area.get("structure", []), + "status": "disconnected" # 初始状态为断开 + } + self.last_update[plc_name][name] = 0 + + def refresh_cache(self): + """后台线程:定期刷新缓存""" + while self.running: + start_time = time.time() + + try: + for plc in self.config["plcs"]: + plc_name = plc["name"] + refresh_interval = plc.get("refresh_interval", 0.5) + client = self.plc_manager.get_plc(plc_name) + + # 检查PLC连接状态 + plc_connected = client.connected + + # 更新PLC连接状态 + with self.lock: + if plc_connected: + self.plc_last_connected[plc_name] = time.time() + self.plc_connection_status[plc_name] = "connected" + else: + if self.plc_last_connected[plc_name] == 0: + self.plc_connection_status[plc_name] = "never_connected" + else: + self.plc_connection_status[plc_name] = "disconnected" + + # 刷新所有可读区域 + for area in plc["areas"]: + if area["type"] in ["read", "read_write"]: + name = area["name"] + try: + data = client.read_db(area["db_number"], area["offset"], area["size"]) + + # 更新区域状态基于PLC连接状态和读取结果 + with self.lock: + if plc_connected and data and len(data) == area["size"]: + self.cache[plc_name][name]["data"] = bytearray(data) + self.cache[plc_name][name]["status"] = "connected" + self.last_update[plc_name][name] = time.time() + else: + self.cache[plc_name][name]["status"] = self.plc_connection_status[plc_name] + # 如果之前有数据,保留旧数据但标记状态 + if self.last_update[plc_name][name] > 0: + self.logger.info(f"PLC {plc_name} area {name} disconnected but keeping last valid data") + except Exception as e: + with self.lock: + self.cache[plc_name][name]["status"] = self.plc_connection_status[plc_name] + self.logger.warning(f"Error updating status for {plc_name}/{name}: {e}") + + """计算刷新一个PLC的时间""" + # 计算实际执行时间 + execution_time = time.time() - start_time + #计算需要睡眠的时间,确保总等于refresh_time + sleep_time = max(0, refresh_interval - execution_time) + time.sleep(sleep_time) + + # 记录实际刷新间隔 + self.logger.debug(f"plc_name: {plc_name}," + f"Cache refresh completed.Execution time: {execution_time:.3f}s," + f"Sleep time: {sleep_time:.3f}s," + f"Total interval: {execution_time + sleep_time:.3f}s") + + time.sleep(self.refresh_interval) + except Exception as e: + self.logger.error(f"Error in refresh_cache: {e}") + time.sleep(self.refresh_interval) + + def start(self): + """启动缓存刷新线程""" + if self.running: + return + + self.running = True + self.thread = threading.Thread( + target=self.refresh_cache, + name="CacheRefreshThread", + daemon=True + ) + self.thread.start() + self.logger.info("Cache manager started") + + def stop(self): + """停止缓存刷新线程""" + if not self.running: + return + + self.running = False + if self.thread: + # 等待线程结束,但设置超时防止卡死 + self.thread.join(timeout=2.0) + if self.thread.is_alive(): + self.logger.warning("Cache refresh thread did not terminate gracefully") + self.thread = None + self.logger.info("Cache manager stopped") + + def get_plc_connection_status(self, plc_name): + """获取PLC连接状态""" + with self.lock: + return self.plc_connection_status.get(plc_name, "unknown") + + def get_last_update_time(self, plc_name, area_name): + """获取区域数据最后更新时间""" + with self.lock: + return self.last_update.get(plc_name, {}).get(area_name, 0) + + def get_summary(self): + """获取缓存摘要信息""" + summary = {} + with self.lock: + for plc_name, areas in self.cache.items(): + summary[plc_name] = {} + for area_name, area in areas.items(): + last_update = self.last_update[plc_name][area_name] + plc_status = self.plc_connection_status.get(plc_name, "unknown") + + # 区域状态应与PLC连接状态一致,除非有有效数据 + area_status = area["status"] + if plc_status == "never_connected": + area_status = "never_connected" + elif plc_status == "disconnected" and self.last_update[plc_name][area_name] == 0: + area_status = "disconnected" + + summary[plc_name][area_name] = { + "status": area_status, + "plc_connection_status": plc_status, + "last_update": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(last_update)) if last_update > 0 else "Never", + "size": area["size"], + "type": area["type"] + } + return summary + + def get_area_status(self, plc_name, area_name): + """获取区域状态""" + with self.lock: + area = self.cache.get(plc_name, {}).get(area_name) + if not area: + return {"status": "not_found", "message": "PLC or area not found"} + + plc_status = self.plc_connection_status.get(plc_name, "unknown") + last_update = self.last_update.get(plc_name, {}).get(area_name, 0) + + # 区域状态应与PLC连接状态一致,除非有有效数据 + area_status = area["status"] + if plc_status == "never_connected": + area_status = "never_connected" + elif plc_status == "disconnected" and last_update == 0: + area_status = "disconnected" + + return { + "status": area_status, + "plc_connection_status": plc_status, + "last_update": last_update, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(last_update)) if last_update > 0 else "Never", + "size": area["size"], + "type": area["type"] + } + + def read_area(self, plc_name, area_name, offset, length): + """单个区域读取""" + with self.lock: + area = self.cache.get(plc_name, {}).get(area_name) + print("read area :",area) + if not area: + return None, "Area not found", "unknown", 0 + + if offset + length > area["size"]: + return None, "Offset out of bounds", "unknown", 0 + + client = self.plc_manager.get_plc(plc_name) + plc_status = self.plc_connection_status.get(plc_name, "unknown") + + # 如果PLC未连接,直接返回错误 + if plc_status != "connected": + return None, f"PLC not connected (status: {plc_status})", plc_status, 0 + + try: + data = client.read_db(area["db_number"], area["offset"] + offset, length) + # 验证数据有效性 + if data and len(data) == length: + # 更新缓存中的这部分数据 + for i in range(length): + area["data"][offset + i] = data[i] + update_time = time.time() + self.last_update[plc_name][area_name] = update_time + area["status"] = "connected" + + return data, None, plc_status, update_time + else: + area["status"] = plc_status + return None, "Invalid data returned", plc_status, 0 + except Exception as e: + area["status"] = plc_status + self.logger.error(f"Read failed for {plc_name}/{area_name}: {e}") + return None, f"Read failed: {str(e)}", plc_status, 0 + + def read_area_bool(self, plc_name, area_name, offset, length): + """单个区域读取""" + with self.lock: + area = self.cache.get(plc_name, {}).get(area_name) + if not area: + return None, "Area not found", "unknown", 0 + + if offset + length > area["size"]: + return None, "Offset out of bounds", "unknown", 0 + + client = self.plc_manager.get_plc(plc_name) + plc_status = self.plc_connection_status.get(plc_name, "unknown") + + # 如果PLC未连接,直接返回错误 + if plc_status != "connected": + return None, f"PLC not connected (status: {plc_status})", plc_status, 0 + + try: + data = client.read_db_bool(area["db_number"], area["offset"] + offset, length) + # 验证数据有效性 + if all(isinstance(val, bool) for val in data.values()): + # 按字典键顺序更新多个值 + for i, val in data.items(): + area["data"][offset + i] = val # 确保offset+i不越界 + + # area["data"][offset] = data.values + update_time = time.time() + self.last_update[plc_name][area_name] = update_time + area["status"] = "connected" + + return data, None, plc_status, update_time + else: + area["status"] = plc_status + return None, "Invalid data returned", plc_status, 0 + except Exception as e: + area["status"] = plc_status + self.logger.error(f"Read failed for {plc_name}/{area_name}: {e}") + return None, f"Read failed: {str(e)}", plc_status, 0 + + def write_area(self, plc_name, area_name, offset, data): + """单个区域写入""" + with self.lock: + area = self.cache.get(plc_name, {}).get(area_name) + if not area: + return False, "Area not found", "unknown", 0 + + if area["type"] not in ["write", "read_write"]: + plc_status = self.plc_connection_status.get(plc_name, "unknown") + return False, "Area is read-only", plc_status, 0 + + if offset + len(data) > area["size"]: + plc_status = self.plc_connection_status.get(plc_name, "unknown") + return False, "Offset out of bounds", plc_status, 0 + + client = self.plc_manager.get_plc(plc_name) + plc_status = self.plc_connection_status.get(plc_name, "unknown") + + # 如果PLC未连接,直接返回错误 + if plc_status != "connected": + return False, f"PLC not connected (status: {plc_status})", plc_status, 0 + + try: + success = client.write_db(area["db_number"], area["offset"] + offset, data) + if success: + # 更新缓存中的这部分数据 + for i in range(len(data)): + area["data"][offset + i] = data[i] + update_time = time.time() + self.last_update[plc_name][area_name] = update_time + area["status"] = "connected (last write)" + + return True, None, plc_status, update_time + else: + area["status"] = plc_status + return False, "Write failed", plc_status, 0 + except Exception as e: + area["status"] = plc_status + self.logger.error(f"Write failed for {plc_name}/{area_name}: {e}") + return False, f"Write failed: {str(e)}", plc_status, 0 + + def batch_write_area(self, plc_name, area_name, offset, data): + """单个区域写入""" + with self.lock: + area = self.cache.get(plc_name, {}).get(area_name) + if not area: + return False, "Area not found", "unknown", 0 + + if area["type"] not in ["write", "read_write"]: + plc_status = self.plc_connection_status.get(plc_name, "unknown") + return False, "Area is read-only", plc_status, 0 + + if offset + len(data) > area["size"]: + plc_status = self.plc_connection_status.get(plc_name, "unknown") + return False, "Offset out of bounds", plc_status, 0 + + client = self.plc_manager.get_plc(plc_name) + plc_status = self.plc_connection_status.get(plc_name, "unknown") + + # 如果PLC未连接,直接返回错误 + if plc_status != "connected": + return False, f"PLC not connected (status: {plc_status})", plc_status, 0 + + try: + for i, byte in enumerate(data): + byte_data = bytes([byte]) + current_offset = offset + (i * 2) + byte_value = byte_data[0] + + value = bytearray(2) + if isinstance(byte_value, int): + set_int(value, 0, byte_value) + data = value + + success = client.batch_write_db(area["db_number"], current_offset, data) + if success: + # 更新缓存中的这部分数据 + for j in range(len(data)): + area["data"][offset + j] = data[j] + update_time = time.time() + self.last_update[plc_name][area_name] = update_time + area["status"] = "connected (last write)" + else: + area["status"] = plc_status + return False, "Write failed", plc_status, 0 + return True, None, plc_status, update_time + except Exception as e: + area["status"] = plc_status + self.logger.error(f"Write failed for {plc_name}/{area_name}: {e}") + return False, f"Write failed: {str(e)}", plc_status, 0 + + def batch_write_bool_area(self, plc_name, area_name, offset, data): + """单个区域写入""" + with self.lock: + area = self.cache.get(plc_name, {}).get(area_name) + if not area: + return False, "Area not found", "unknown", 0 + + if area["type"] not in ["write", "read_write"]: + plc_status = self.plc_connection_status.get(plc_name, "unknown") + return False, "Area is read-only", plc_status, 0 + + if offset + len(data) > area["size"]: + plc_status = self.plc_connection_status.get(plc_name, "unknown") + return False, "Offset out of bounds", plc_status, 0 + + client = self.plc_manager.get_plc(plc_name) + plc_status = self.plc_connection_status.get(plc_name, "unknown") + + # 如果PLC未连接,直接返回错误 + if plc_status != "connected": + return False, f"PLC not connected (status: {plc_status})", plc_status, 0 + + try: + value = bytearray(offset + 1) + for bit, bit_value in enumerate(data): + set_bool(value, offset, bit, bit_value) + data = value + + success = client.batch_write_db_bool(area["db_number"], offset, data) + if success: + # 更新缓存中的这部分数据 + for j in range(len(data)): + area["data"][offset + j] = data[j] + update_time = time.time() + self.last_update[plc_name][area_name] = update_time + area["status"] = "connected (last write)" + else: + area["status"] = plc_status + return False, "Write failed", plc_status, 0 + return True, None, plc_status, update_time + except Exception as e: + area["status"] = plc_status + self.logger.error(f"Write failed for {plc_name}/{area_name}: {e}") + return False, f"Write failed: {str(e)}", plc_status, 0 + + def write_area_bool(self, plc_name, area_name, offset, data): + """单个区域写入""" + with self.lock: + area = self.cache.get(plc_name, {}).get(area_name) + if not area: + return False, "Area not found", "unknown", 0 + + if area["type"] not in ["write", "read_write"]: + plc_status = self.plc_connection_status.get(plc_name, "unknown") + return False, "Area is read-only", plc_status, 0 + + if offset + len(data) > area["size"]: + plc_status = self.plc_connection_status.get(plc_name, "unknown") + return False, "Offset out of bounds", plc_status, 0 + + client = self.plc_manager.get_plc(plc_name) + plc_status = self.plc_connection_status.get(plc_name, "unknown") + + # 如果PLC未连接,直接返回错误 + if plc_status != "connected": + return False, f"PLC not connected (status: {plc_status})", plc_status, 0 + + try: + success = client.write_db_bool(area["db_number"], area["offset"] + offset, data) + if success: + # 更新缓存中的这部分数据 + for i in range(len(data)): + area["data"][offset + i] = data[i] + update_time = time.time() + self.last_update[plc_name][area_name] = update_time + area["status"] = "connected (last write)" + + return True, None, plc_status, update_time + else: + area["status"] = plc_status + return False, "Write failed", plc_status, 0 + except Exception as e: + area["status"] = plc_status + self.logger.error(f"Write failed for {plc_name}/{area_name}: {e}") + return False, f"Write failed: {str(e)}", plc_status, 0 + + def batch_read(self, requests): + """批量读取""" + results = [] + + for req in requests: + plc_name = req["plc_name"] + area_name = req["area_name"] + offset = req.get("offset", 0) + length = req.get("length", None) + + # 获取PLC连接状态 + plc_status = self.plc_connection_status.get(plc_name, "unknown") + + # 如果PLC未连接,直接返回错误 + if plc_status != "connected": + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "error", + "plc_connection_status": plc_status, + "last_update": 0, + "last_update_formatted": "N/A", + "message": f"PLC not connected (status: {plc_status})" + }) + continue + + area = self.cache.get(plc_name, {}).get(area_name) + if not area: + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "error", + "plc_connection_status": plc_status, + "last_update": 0, + "last_update_formatted": "N/A", + "message": "Area not found" + }) + continue + + # 如果未指定length,读取整个区域 + if length is None: + length = area["size"] - offset + + data, error, _, update_time = self.read_area(plc_name, area_name, offset, length) + if error: + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "error", + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) if update_time > 0 else "Never", + "message": error + }) + else: + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "success", + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)), + "offset": offset, + "length": length, + "data": list(data) + }) + return results + + def batch_write(self, requests): + """批量写入""" + results = [] + for req in requests: + plc_name = req["plc_name"] + area_name = req["area_name"] + offset = req["offset"] + data = bytes(req["data"]) + + # 获取PLC连接状态 + plc_status = self.plc_connection_status.get(plc_name, "unknown") + + # 如果PLC未连接,直接返回错误 + if plc_status != "connected": + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "error", + "plc_connection_status": plc_status, + "last_update": 0, + "last_update_formatted": "N/A", + "message": f"PLC not connected (status: {plc_status})", + "offset": offset + }) + continue + success, error, _, update_time = self.batch_write_area(plc_name, area_name, offset, data) + if error: + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "error", + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) if update_time > 0 else "Never", + "message": error, + "offset": offset + }) + else: + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "success", + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)), + "offset": offset, + "length": len(data) + }) + return results + + def batch_write_bool(self, requests): + """批量写入""" + results = [] + for req in requests: + plc_name = req["plc_name"] + area_name = req["area_name"] + offset = req["offset"] + data = bytes(req["data"]) + + # 获取PLC连接状态 + plc_status = self.plc_connection_status.get(plc_name, "unknown") + + # 如果PLC未连接,直接返回错误 + if plc_status != "connected": + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "error", + "plc_connection_status": plc_status, + "last_update": 0, + "last_update_formatted": "N/A", + "message": f"PLC not connected (status: {plc_status})", + "offset": offset + }) + continue + + success, error, _, update_time = self.batch_write_bool_area(plc_name, area_name, offset, data) + if error: + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "error", + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)) if update_time > 0 else "Never", + "message": error, + "offset": offset + }) + else: + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "success", + "plc_connection_status": plc_status, + "last_update": update_time, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(update_time)), + "offset": offset, + "length": len(data) + }) + return results + + def read_generic(self, plc_name, area_name, offset, data_type, count=1): + """通用读取接口""" + with self.lock: + area = self.cache.get(plc_name, {}).get(area_name) + print("area:",area) + if not area: + return None, "Area not found", "unknown", 0 + + if area["type"] not in ["read", "read_write"]: + plc_status = self.plc_connection_status.get(plc_name, "unknown") + return None, "Area is read-only", plc_status, 0 + + # 计算实际DB偏移 + db_offset = area["offset"] + offset + + # 确保在区域内 + if data_type == 'bool': + required_size = (offset + count + 7) // 8 + elif data_type in ['int', 'word']: + required_size = 2 * count + elif data_type in ['dint', 'dword', 'real']: + required_size = 4 * count + else: # byte + required_size = count + + if db_offset + required_size > area["size"] or db_offset < 0: + plc_status = self.plc_connection_status.get(plc_name, "unknown") + return None, "Offset out of bounds", plc_status, 0 + + client = self.plc_manager.get_plc(plc_name) + plc_status = self.plc_connection_status.get(plc_name, "unknown") + + # 如果PLC未连接,直接返回错误 + if plc_status != "connected": + return None, f"PLC not connected (status: {plc_status})", plc_status, 0 + + try: + # 使用Snap7Client的read_generic方法 + result = client.read_generic(area["db_number"], db_offset, data_type, count) + if result is None: + return None, "Read failed", plc_status, 0 + + # 对于bool类型,需要特殊处理缓存 + if data_type == 'bool': + for i in range(count): + byte_offset = offset // 8 + i // 8 + bit_offset = (offset % 8) + (i % 8) + if bit_offset >= 8: + byte_offset += 1 + bit_offset -= 8 + + # 读取当前字节值 + current_byte = area["data"][byte_offset] + if result[i]: + # 设置位为1 + new_byte = current_byte | (1 << bit_offset) + else: + # 设置位为0 + new_byte = current_byte & ~(1 << bit_offset) + area["data"][byte_offset] = new_byte + else: + # 对于其他类型,直接更新缓存 + if not isinstance(result, list): + result = [result] + + if data_type == 'byte': + item_size = 1 + elif data_type in ['int', 'word']: + item_size = 2 + else: # dint, dword, real + item_size = 4 + + for i, val in enumerate(result): + item_offset = offset + i * item_size + if data_type == 'byte': + area["data"][item_offset] = val & 0xFF + elif data_type in ['int', 'word']: + # 2字节数据 + packed = struct.pack(">h" if data_type == "int" else ">H", val) + for j in range(2): + area["data"][item_offset + j] = packed[j] + elif data_type in ['dint', 'dword', 'real']: + # 4字节数据 + packed = struct.pack( + ">l" if data_type == "dint" else + ">I" if data_type == "dword" else + ">f", + val + ) + for j in range(4): + area["data"][item_offset + j] = packed[j] + + update_time = time.time() + self.last_update[plc_name][area_name] = update_time + area["status"] = "connected" + return result, None, plc_status, update_time + except Exception as e: + area["status"] = plc_status + self.logger.error(f"Read failed for {plc_name}/{area_name}: {e}") + return None, f"Read failed: {str(e)}", plc_status, 0 + + def write_generic(self, plc_name, area_name, offset, data_type, value): + """通用写入接口""" + with self.lock: + area = self.cache.get(plc_name, {}).get(area_name) + if not area: + return False, "Area not found", "unknown", 0 + + if area["type"] not in ["write", "read_write"]: + plc_status = self.plc_connection_status.get(plc_name, "unknown") + return False, "Area is read-only", plc_status, 0 + + # 计算实际DB偏移 + db_offset = area["offset"] + offset + + # 确保在区域内 + if data_type == 'bool': + # 确定存储这些布尔值需要多少字节。 + required_size = (offset + (len(value) if isinstance(value, list) else 1) + 7) // 8 + elif data_type in ['int', 'word']: + required_size = 2 * (len(value) if isinstance(value, list) else 1) + elif data_type in ['dint', 'dword', 'real']: + required_size = 4 * (len(value) if isinstance(value, list) else 1) + else: # byte + required_size = len(value) if isinstance(value, list) else 1 + + if db_offset + required_size > area["size"] or db_offset < 0: + plc_status = self.plc_connection_status.get(plc_name, "unknown") + return False, "Offset out of bounds", plc_status, 0 + + client = self.plc_manager.get_plc(plc_name) + plc_status = self.plc_connection_status.get(plc_name, "unknown") + + # 如果PLC未连接,直接返回错误 + if plc_status != "connected": + return False, f"PLC not connected (status: {plc_status})", plc_status, 0 + + try: + # 使用Snap7Client的write_generic方法 + success = client.write_generic(area["db_number"], db_offset, data_type, value) + if success: + # 根据数据类型更新缓存 + if data_type == 'bool': + # 处理bool写入 + if not isinstance(value, list): + value = [value] + + for i, val in enumerate(value): + byte_offset = offset // 8 + i // 8 + bit_offset = (offset % 8) + (i % 8) + if bit_offset >= 8: + byte_offset += 1 + bit_offset -= 8 + + # 读取当前字节值 + current_byte = area["data"][byte_offset] + if val: + # 设置位为1 + new_byte = current_byte | (1 << bit_offset) + else: + # 设置位为0 + new_byte = current_byte & ~(1 << bit_offset) + area["data"][byte_offset] = new_byte + elif data_type == 'byte': + # 处理byte写入 + if not isinstance(value, list): + value = [value] + + for i, val in enumerate(value): + area["data"][offset + i] = val & 0xFF + elif data_type in ['int', 'word']: + # 处理int/word写入 + if not isinstance(value, list): + value = [value] + + for i, val in enumerate(value): + # 2字节数据 + packed = struct.pack(">h" if data_type == "int" else ">H", val) + for j in range(2): + area["data"][offset + i * 2 + j] = packed[j] + elif data_type in ['dint', 'dword', 'real']: + # 处理dint/dword/real写入 + if not isinstance(value, list): + value = [value] + + for i, val in enumerate(value): + # 4字节数据 + packed = struct.pack( + ">l" if data_type == "dint" else + ">I" if data_type == "dword" else + ">f", + val + ) + for j in range(4): + area["data"][offset + i * 4 + j] = packed[j] + + update_time = time.time() + self.last_update[plc_name][area_name] = update_time + area["status"] = "connected (last write)" + return True, None, plc_status, update_time + else: + area["status"] = plc_status + return False, "Write failed", plc_status, 0 + except Exception as e: + area["status"] = plc_status + self.logger.error(f"Write failed for {plc_name}/{area_name}: {e}") + return False, f"Write failed: {str(e)}", plc_status, 0 + + def get_parsed_data(self, plc_name, area_name): + """获取解析后的数据""" + from data_parser import parse_data + + with self.lock: + area = self.cache.get(plc_name, {}).get(area_name) + if not area: + return {"error": "Area not found"} + + plc_status = self.plc_connection_status.get(plc_name, "unknown") + last_update = self.last_update.get(plc_name, {}).get(area_name, 0) + + # 区域状态应与PLC连接状态一致,除非有有效数据 + area_status = area["status"] + if plc_status == "never_connected": + area_status = "never_connected" + elif plc_status == "disconnected" and last_update == 0: + area_status = "disconnected" + + structure = area.get("structure", []) + if structure: + parsed = parse_data(area["data"], structure) + parsed["status"] = area_status + parsed["plc_connection_status"] = plc_status + parsed["last_update"] = last_update + parsed["last_update_formatted"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(last_update)) if last_update > 0 else "Never" + return parsed + else: + return { + "raw_data": list(area["data"]), + "status": area_status, + "plc_connection_status": plc_status, + "last_update": last_update, + "last_update_formatted": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(last_update)) if last_update > 0 else "Never" + } \ No newline at end of file diff --git a/gateway/config_loader.py b/gateway/config_loader.py new file mode 100644 index 0000000..772dfd3 --- /dev/null +++ b/gateway/config_loader.py @@ -0,0 +1,10 @@ +import json +import os + +def load_config(config_path="config/config.json"): + """加载配置文件""" + if not os.path.exists(config_path): + raise FileNotFoundError(f"Configuration file not found: {config_path}") + + with open(config_path, "r") as f: + return json.load(f) \ No newline at end of file diff --git a/gateway/config_manager.py b/gateway/config_manager.py new file mode 100644 index 0000000..b01e50b --- /dev/null +++ b/gateway/config_manager.py @@ -0,0 +1,87 @@ +import json +import os +import logging +from config_validator import ConfigValidator + +class ConfigManager: + """配置文件管理器""" + + def __init__(self, config_path="../config/config.json"): + self.config_path = config_path + self.config = None + self.load_config() + + def load_config(self): + """加载配置文件""" + try: + if not os.path.exists(self.config_path): + # 尝试从备份恢复 + backup_path = self.config_path + ".bak" + if os.path.exists(backup_path): + logging.warning(f"Main config not found, using backup: {backup_path}") + with open(backup_path, 'r') as src, open(self.config_path, 'w') as dst: + config_data = src.read() + dst.write(config_data) + else: + raise FileNotFoundError(f"Configuration file not found: {self.config_path}") + + with open(self.config_path, 'r') as f: + self.config = json.load(f) + + # 验证配置 + is_valid, error = ConfigValidator.validate_config(self.config) + if not is_valid: + logging.error(f"Invalid configuration: {error}") + # 尝试从备份恢复 + backup_path = self.config_path + ".bak" + if os.path.exists(backup_path): + logging.warning("Attempting to load from backup configuration") + with open(backup_path, 'r') as f: + self.config = json.load(f) + is_valid, error = ConfigValidator.validate_config(self.config) + if not is_valid: + raise ValueError(f"Backup config also invalid: {error}") + else: + raise ValueError(f"Invalid configuration: {error}") + + return True, None + except Exception as e: + logging.error(f"Failed to load config: {e}") + self.config = {"plcs": []} + return False, str(e) + + def get_config(self): + """获取当前配置""" + return self.config + + def validate_config(self, config): + """验证配置是否有效""" + return ConfigValidator.validate_config(config) + + def save_config(self, new_config): + """保存配置文件""" + try: + # 验证配置 + is_valid, error = self.validate_config(new_config) + if not is_valid: + return False, f"Invalid configuration: {error}" + + # 备份旧配置 + backup_path = self.config_path + ".bak" + if os.path.exists(self.config_path): + with open(self.config_path, 'r') as src, open(backup_path, 'w') as dst: + dst.write(src.read()) + + # 保存新配置 + with open(self.config_path, 'w') as f: + json.dump(new_config, f, indent=2) + + self.config = new_config + return True, None + except Exception as e: + logging.error(f"Failed to save config: {e}") + return False, str(e) + + def reload_config(self): + """重新加载配置""" + return self.load_config() \ No newline at end of file diff --git a/gateway/config_validator.py b/gateway/config_validator.py new file mode 100644 index 0000000..f3b348b --- /dev/null +++ b/gateway/config_validator.py @@ -0,0 +1,91 @@ +import json +from jsonschema import validate, Draft7Validator, FormatChecker + +class ConfigValidator: + """配置文件验证器""" + + SCHEMA = { + "type": "object", + "properties": { + "plcs": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1}, + "ip": {"type": "string", "format": "ipv4"}, + "rack": {"type": "integer", "minimum": 0}, + "slot": {"type": "integer", "minimum": 0}, + "refresh_interval": { + "type": "number", + "minimum": 0.01, + "default": 0.5 + }, + "areas": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1}, + "type": {"type": "string", "enum": ["read", "write", "read_write"]}, + "db_number": {"type": "integer", "minimum": 1}, + "offset": {"type": "integer", "minimum": 0}, + "size": {"type": "integer", "minimum": 1}, + "structure": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "type": {"type": "string", "enum": ["bool", "byte", "int", "dint", "real", "word", "dword"]}, + "offset": {"type": "integer", "minimum": 0}, + "bit": {"type": "integer", "minimum": 0, "maximum": 7} # 修复了这里 + }, + "required": ["name", "type", "offset"] + } + } + }, + "required": ["name", "type", "db_number", "offset", "size"] + } + } + }, + "required": ["name", "ip", "rack", "slot", "areas"] + } + } + }, + "required": ["plcs"] + } + + """ + @staticmethod:静态方法装饰器,使validate_config不依赖于类的实例,可以通过类直接调用 + 如ConfigValidator.validate_config(config) + """ + @staticmethod + def validate_config(config): + """使用JSONSchema验证配置是否符合规范""" + try: + # 添加IPv4格式验证 + validator = Draft7Validator( + ConfigValidator.SCHEMA, + format_checker=FormatChecker(["ipv4"]) + ) + validator.validate(config) + return True, None + except Exception as e: + return False, str(e) + + @staticmethod + def is_valid_ip(ip): + """验证IP地址格式""" + parts = ip.split('.') + if len(parts) != 4: + return False + for part in parts: + if not part.isdigit(): + return False + num = int(part) + if num < 0 or num > 255: + return False + return True \ No newline at end of file diff --git a/gateway/data_parser.py b/gateway/data_parser.py new file mode 100644 index 0000000..0c4e236 --- /dev/null +++ b/gateway/data_parser.py @@ -0,0 +1,55 @@ +from struct import unpack +import time + +def parse_data(data, structure): + """解析结构化数据""" + result = {"raw_data": list(data)} + + if not structure: + return result + + result["parsed"] = {} + for field in structure: + offset = field["offset"] + name = field["name"] + data_type = field["type"] + + try: + if data_type == "int": + if offset + 2 > len(data): + raise ValueError("Offset out of bounds") + val = unpack(">h", data[offset:offset+2])[0] + elif data_type == "dint": + if offset + 4 > len(data): + raise ValueError("Offset out of bounds") + val = unpack(">l", data[offset:offset+4])[0] + elif data_type == "real": + if offset + 4 > len(data): + raise ValueError("Offset out of bounds") + val = unpack(">f", data[offset:offset+4])[0] + elif data_type == "bool": + bit = field.get("bit", 0) + if offset >= len(data): + raise ValueError("Offset out of bounds") + byte = data[offset] + val = bool((byte >> bit) & 1) + elif data_type == "byte": + if offset >= len(data): + raise ValueError("Offset out of bounds") + val = data[offset] + elif data_type == "word": + if offset + 2 > len(data): + raise ValueError("Offset out of bounds") + val = (data[offset] << 8) | data[offset + 1] + elif data_type == "dword": + if offset + 4 > len(data): + raise ValueError("Offset out of bounds") + val = (data[offset] << 24) | (data[offset+1] << 16) | (data[offset+2] << 8) | data[offset+3] + else: + val = f"Unknown type: {data_type}" + + result["parsed"][name] = val + except Exception as e: + result["parsed"][name] = f"Error: {str(e)}" + + return result \ No newline at end of file diff --git a/gateway/main.py b/gateway/main.py new file mode 100644 index 0000000..0295fac --- /dev/null +++ b/gateway/main.py @@ -0,0 +1,109 @@ +import logging +import time +import threading +from config_loader import load_config +from plc_manager import PLCManager +from cache_manager import CacheManager +from api_server import APIServer +from config_manager import ConfigManager + +class GatewayApp: + """PLC网关应用程序主类""" + + def __init__(self, config_path="../config/config.json"): + self.config_path = config_path + self.config_manager = ConfigManager(config_path) + self.plc_manager = None + self.cache_manager = None + self.api_server = None + self.reload_flag = False + self.reload_lock = threading.Lock() + self.logger = logging.getLogger("GatewayApp") + + # 加载初始配置 + self.load_configuration() + + def load_configuration(self): + """加载配置并初始化组件""" + # 加载配置 + if not self.config_manager.load_config(): + self.logger.error("Failed to load initial configuration") + return False + + config = self.config_manager.get_config() + + # 重新初始化PLC连接 + if self.plc_manager: + self.logger.info("Reinitializing PLC connections...") + self.plc_manager = PLCManager(config["plcs"]) + self.plc_manager.connect_all() + + # 重新初始化缓存 + if self.cache_manager: + self.logger.info("Stopping existing cache manager...") + self.cache_manager.stop() + + self.logger.info("Initializing cache manager...") + self.cache_manager = CacheManager(config, self.plc_manager, app=self) + self.cache_manager.start() + + # 重新初始化API服务器 + if self.api_server: + self.logger.info("API server already running") + else: + self.logger.info("Starting API server...") + self.api_server = APIServer(self.cache_manager, self.config_path) + self.api_server.start() + + self.logger.info("Configuration loaded successfully") + return True + + def check_for_reload(self): + """检查是否需要重载配置""" + while True: + with self.reload_lock: + if self.reload_flag: + self.reload_flag = False + self.load_configuration() + time.sleep(1) + + def request_reload(self): + """请求重载配置""" + with self.reload_lock: + self.reload_flag = True + self.logger.info("Configuration reload requested") + + def run(self): + """运行主程序""" + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s' + ) + + self.logger.info("Starting PLC Gateway...") + + # 启动配置重载检查线程 + reload_thread = threading.Thread( + target=self.check_for_reload, + name="ConfigReloadThread", + daemon=True + ) + reload_thread.start() + + try: + # 保持主程序运行 + while True: + time.sleep(1) + except KeyboardInterrupt: + self.logger.info("Shutting down gracefully...") + finally: + if self.cache_manager: + self.cache_manager.stop() + self.logger.info("Shutdown complete") + +def main(): + app = GatewayApp() + app.run() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/gateway/plc_manager.py b/gateway/plc_manager.py new file mode 100644 index 0000000..038b1e8 --- /dev/null +++ b/gateway/plc_manager.py @@ -0,0 +1,42 @@ +from snap7_client import Snap7Client +import logging + +class PLCManager: + """PLC连接管理器,管理多个PLC连接""" + + def __init__(self, plcs_config): + """ + 初始化PLC管理器 + + Args: + plcs_config: PLC配置列表 + """ + self.plcs = {} + for plc_config in plcs_config: + name = plc_config["name"] + self.plcs[name] = Snap7Client( + plc_config["ip"], + plc_config["rack"], + plc_config["slot"] + ) + self.logger = logging.getLogger("PLCManager") + + def get_plc(self, name): + """获取指定名称的PLC客户端""" + return self.plcs.get(name) + + def connect_all(self): + """连接所有配置的PLC""" + for name, client in self.plcs.items(): + client.connect() + + def get_connection_status(self): + """获取所有PLC的连接状态""" + status = {} + for name, client in self.plcs.items(): + status[name] = { + "ip": client.ip, + "connected": client.connected, + "retry_count": client.retry_count + } + return status \ No newline at end of file diff --git a/gateway/snap7_client.py b/gateway/snap7_client.py new file mode 100644 index 0000000..e972dd0 --- /dev/null +++ b/gateway/snap7_client.py @@ -0,0 +1,448 @@ +import snap7 +import logging +from threading import Lock +import time +from snap7.util import * +import ast + +class Snap7Client: + """Snap7客户端,处理与PLC的通信""" + + def __init__(self, ip, rack, slot, max_retries=5, retry_base_delay=1): + """ + 初始化Snap7客户端 + + Args: + ip: PLC IP地址 + rack: Rack编号 + slot: Slot编号 + max_retries: 最大重试次数 + retry_base_delay: 基础重试延迟(秒) + """ + self.ip = ip + self.rack = rack + self.slot = slot + self.client = snap7.client.Client() + self.lock = Lock() + self.connected = False + self.max_retries = max_retries + self.retry_base_delay = retry_base_delay + self.last_connect_attempt = 0 + self.retry_count = 0 + self.logger = logging.getLogger(f"Snap7Client[{ip}]") + + def is_valid_connection(self): + """检查连接是否真正有效""" + try: + # 尝试读取PLC的运行状态 + cpu_state = self.client.get_cpu_state() + print("当前 CPU 状态:", cpu_state) + return cpu_state in ['S7CpuStatusRun', 'S7CpuStatusStop'] + except: + return False + + def connect(self): + """建立与PLC的连接并验证""" + current_time = time.time() + # 指数退避重试 + if self.retry_count > 0: + delay = min(self.retry_base_delay * (2 ** (self.retry_count - 1)), 30) + if current_time - self.last_connect_attempt < delay: + return False # 未到重试时间 + + self.last_connect_attempt = current_time + try: + self.client.connect(self.ip, self.rack, self.slot) + + # 验证连接是否真正有效 + if self.client.get_connected() and self.is_valid_connection(): + self.connected = True + self.retry_count = 0 # 重置重试计数 + self.logger.info(f"Successfully connected to PLC {self.ip}") + return True + else: + self.connected = False + self.logger.warning(f"Connection to {self.ip} established but PLC is not responding") + try: + self.client.disconnect() + except: + pass + return False + except Exception as e: + self.retry_count = min(self.retry_count + 1, self.max_retries) + self.logger.error(f"Connection to {self.ip} failed (attempt {self.retry_count}/{self.max_retries}): {e}") + self.connected = False + return False + + def read_db(self, db_number, offset, size): + """ + 从DB块读取数据 + + Args: + db_number: DB编号 + offset: 起始偏移量 + size: 读取字节数 + + Returns: + bytearray: 读取的数据,如果失败返回None + """ + if not self.connected and not self.connect(): + self.logger.warning(f"Read failed: not connected to {self.ip}") + return None # 返回None而不是零填充数据 + + try: + with self.lock: # 进入锁,其他线程需等待 + data = self.client.db_read(db_number, offset, size) + # 验证返回数据的有效性 + if data is None or len(data) != size: + self.connected = False + self.logger.error(f"Read DB{db_number} returned invalid data size (expected {size}, got {len(data) if data else 0})") + return None + return data + except Exception as e: + self.logger.error(f"Read DB{db_number} error: {e}") + self.connected = False + return None + + def read_db_bool(self, db_number, offset, bit_length): + """ + 从 DB 块中读取一个字节,并提取其中的多个 BOOL 位 + + Args: + db_number (int): DB块编号 + offset (int): 要读取的字节偏移地址 + bit_length: 第几位,如1,表示第1位 + + Returns: + result:返回位值 + """ + if not self.connected and not self.connect(): + self.logger.warning(f"Read failed: not connected to {self.ip}") + return None # 返回None而不是零填充数据 + + try: + with self.lock: + data = self.client.db_read(db_number, offset, 1) + result = {} + + for bit in range(bit_length): + result[bit] = bool(data[0] & (1 << bit)) + + if result is None or len(result) != bit_length: + self.connected = False + self.logger.error(f"Read DB{db_number} returned invalid data size (expected {bit_length}, got {len(result) if data else 0})") + return None + return result + except Exception as e: + self.logger.error(f"Read DB{db_number} error: {e}") + self.connected = False + return None + + def write_db(self, db_number, offset, data): + """ + 向DB块写入数据 + + Args: + db_number: DB编号 + offset: 起始偏移量 + data: 要写入的数据 + + Returns: + bool: 是否写入成功 + """ + if not self.connected and not self.connect(): + self.logger.warning(f"Write failed: not connected to {self.ip}") + return False + + try: + + self.client.db_write(db_number, offset, data) + self.logger.debug(f"Wrote {len(data)} bytes to DB{db_number} offset {offset}") + return True + except Exception as e: + self.logger.error(f"Write DB{db_number} error: {e}") + self.connected = False + return False + + def batch_write_db(self, db_number, offset, data): + """ + 向DB块写入数据 + + Args: + db_number: DB编号 + offset: 起始偏移量 + data: 要写入的数据 + + Returns: + bool: 是否写入成功 + """ + if not self.connected and not self.connect(): + self.logger.warning(f"Write failed: not connected to {self.ip}") + return False + + try: + with self.lock: + self.client.db_write(db_number, offset, data) + self.logger.debug(f"Wrote {len(data)} bytes to DB{db_number} offset {offset}") + return True + except Exception as e: + self.logger.error(f"Write DB{db_number} error: {e}") + self.connected = False + return False + + def write_db_bool(self, db_number, offset, data): + """ + 向DB块写入数据 + + Args: + db_number: DB编号 + offset: 起始偏移量 + data: 要写入的bool类型数据 + + Returns: + bool: 是否写入成功 + """ + if not self.connected and not self.connect(): + self.logger.warning(f"Write failed: not connected to {self.ip}") + return False + + try: + with self.lock: + + self.client.db_write(db_number, offset, data) + self.logger.debug(f"Wrote {len(data)} bytes to DB{db_number} offset {offset}") + return True + except Exception as e: + self.logger.error(f"Write DB{db_number} error: {e}") + self.connected = False + return False + + def batch_write_db_bool(self, db_number, offset, data): + """ + 向DB块写入数据 + + Args: + db_number: DB编号 + offset: 起始偏移量 + data: 要写入的bool类型数据 + + Returns: + bool: 是否写入成功 + """ + if not self.connected and not self.connect(): + self.logger.warning(f"Write failed: not connected to {self.ip}") + return False + + try: + with self.lock: + print(db_number, offset, data) + self.client.db_write(db_number, offset, data) + self.logger.debug(f"Wrote {len(data)} bytes to DB{db_number} offset {offset}") + return True + except Exception as e: + self.logger.error(f"Write DB{db_number} error: {e}") + self.connected = False + return False + + def read_generic(self, db_number, offset, data_type, count=1): + """ + 通用读取接口,支持多种数据类型 + Args: + db_number: DB块编号 + offset: 起始偏移量(字节或位,对于bool类型) + data_type: 数据类型 ('bool', 'byte', 'int', 'word', 'real', 'dint', 'dword') + count: 要读取的数据个数 + Returns: + 解析后的数据(单个值或值列表),失败返回None + """ + if not self.connected and not self.connect(): + self.logger.warning(f"Read failed: not connected to {self.ip}") + return None + + try: + if data_type == 'bool': + # 对于bool,offset是位偏移 + byte_offset = offset // 8 + bit_offset = offset % 8 + # 计算需要读取的字节数 + last_bit = bit_offset + count - 1 + last_byte = last_bit // 8 + total_bytes = last_byte - byte_offset + 1 + + # 读取原始字节数据 + data = self.read_db(db_number, byte_offset, total_bytes) + if data is None: + return None + + # 解析bool值 + result = [] + for i in range(count): + current_bit = bit_offset + i + byte_idx = current_bit // 8 + bit_idx = current_bit % 8 + result.append(bool(data[byte_idx] & (1 << bit_idx))) + + return result[0] if count == 1 else result + + elif data_type == 'byte': + data = self.read_db(db_number, offset, count) + if data is None: + return None + return [data[i] for i in range(count)] if count > 1 else data[0] + + elif data_type in ['int', 'word']: + total_bytes = 2 * count + data = self.read_db(db_number, offset, total_bytes) + if data is None: + return None + + result = [] + for i in range(count): + if data_type == 'int': + result.append(get_int(data, i * 2)) + else: # word + result.append(get_word(data, i * 2)) + return result[0] if count == 1 else result + + elif data_type in ['dint', 'dword', 'real']: + total_bytes = 4 * count + data = self.read_db(db_number, offset, total_bytes) + if data is None: + return None + + result = [] + for i in range(count): + if data_type == 'dint': + result.append(get_dint(data, i * 4)) + elif data_type == 'dword': + result.append(get_dword(data, i * 4)) + else: # real + result.append(get_real(data, i * 4)) + return result[0] if count == 1 else result + + else: + self.logger.error(f"Unsupported data type: {data_type}") + return None + + except Exception as e: + self.logger.error(f"Error reading {data_type} from DB{db_number} offset {offset}: {e}") + return None + + def write_generic(self, db_number, offset, data_type, value): + """ + 通用写入接口,支持多种数据类型 + Args: + db_number: DB块编号 + offset: 起始偏移量(字节或位,对于bool类型) + data_type: 数据类型 ('bool', 'byte', 'int', 'word', 'real', 'dint', 'dword') + value: 要写入的值(可以是单个值或列表) + Returns: + bool: 是否写入成功 + """ + if not self.connected and not self.connect(): + self.logger.warning(f"Write failed: not connected to {self.ip}") + return False + + try: + if data_type == 'bool': + # 对于bool,offset是位偏移 + byte_offset = offset // 8 + bit_offset = offset % 8 + + # 读取当前字节 + current_byte = self.read_db(db_number, byte_offset, 1) + if current_byte is None: + return False + + # 修改特定位 + if isinstance(value, list): + # 多个bool值 + for i, val in enumerate(value): + current_bit = bit_offset + i + byte_idx = current_bit // 8 + bit_idx = current_bit % 8 + + if val: + current_byte[0] |= (1 << bit_idx) + else: + current_byte[0] &= ~(1 << bit_idx) + else: + # 单个bool值 + if value: + current_byte[0] |= (1 << bit_offset) + else: + current_byte[0] &= ~(1 << bit_offset) + + # 写回修改后的字节 + return self.write_db_bool(db_number, byte_offset, current_byte) + + elif data_type == 'byte': + if isinstance(value, list): + # 批量写入 + for i, val in enumerate(value): + if val < 0 or val > 255: + self.logger.error(f"Byte value out of range: {val}") + return False + if not self.write_db(db_number, offset + i, bytes([val])): + return False + return True + else: + # 单个字节 + if value < 0 or value > 255: + self.logger.error(f"Byte value out of range: {value}") + return False + return self.write_db(db_number, offset, bytes([value])) + + elif data_type in ['int', 'word']: + if not isinstance(value, list): + value = [value] + + for i, val in enumerate(value): + # 确保int值在范围内 + if data_type == 'int' and (val < -32768 or val > 32767): + self.logger.error(f"Int value out of range: {val}") + return False + elif data_type == 'word' and (val < 0 or val > 65535): + self.logger.error(f"Word value out of range: {val}") + return False + + data = bytearray(2) + if data_type == 'int': + set_int(data, 0, val) + else: + set_word(data, 0, val) + + if not self.write_db(db_number, offset + i * 2, data): + return False + return True + + elif data_type in ['dint', 'dword', 'real']: + if not isinstance(value, list): + value = [value] + + for i, val in enumerate(value): + data = bytearray(4) + if data_type == 'dint': + if val < -2147483648 or val > 2147483647: + self.logger.error(f"DInt value out of range: {val}") + return False + set_dint(data, 0, val) + elif data_type == 'dword': + if val < 0 or val > 4294967295: + self.logger.error(f"DWord value out of range: {val}") + return False + set_dword(data, 0, val) + else: # real + set_real(data, 0, float(val)) + + if not self.write_db(db_number, offset + i * 4, data): + return False + return True + + else: + self.logger.error(f"Unsupported data type: {data_type}") + return False + + except Exception as e: + self.logger.error(f"Error writing {data_type} to DB{db_number} offset {offset}: {e}") + return False \ No newline at end of file diff --git a/gateway/templates/api_doc.html b/gateway/templates/api_doc.html new file mode 100644 index 0000000..e8adcde --- /dev/null +++ b/gateway/templates/api_doc.html @@ -0,0 +1,670 @@ + + + PLC Gateway API Documentation + + + +

PLC Gateway API Documentation

+ + + +

Status API

+ +
+

System Status

+
+ GET + /api/status +
+

获取系统状态信息,包括启动时间、PLC数量和缓存大小。

+ +

响应示例

+
+ { + "status": "running", + "start_time": "2023-10-30 14:30:22", + "plc_count": 2, + "cache_size": 11000, + "plc_statuses": { + "PLC1": { + "status": "connected", + "last_connected": "2023-10-30 14:35:10" + }, + "PLC2": { + "status": "disconnected", + "last_connected": "Never" + } + } + } +
+
+ +
+

Area Status

+
+ GET + /api/status// +
+

获取指定PLC区域的状态信息。

+ +

路径参数

+ + + + + + + + + + + + + +
参数描述
plc_namePLC名称(如PLC1)
area_name区域名称(如DB100_Read)
+ +

响应示例

+
+ { + "status": "connected", + "plc_connection_status": "connected", + "last_update": 1698754321.456, + "last_update_formatted": "2023-10-30 14:12:01", + "size": 4000, + "type": "read" + } +
+
+ +

Data API

+ +
+

Single Read

+
+ GET + /api/read//// +
+

从指定区域读取数据。

+ +

路径参数

+ + + + + + + + + + + + + + + + + + + + + +
参数描述
plc_namePLC名称(如PLC1)
area_name区域名称(如DB100_Read)
offset起始偏移量(字节)
length读取长度(字节)
+ +

响应示例

+
+ { + "status": "success", + "plc_name": "PLC1", + "area_name": "DB100_Read", + "offset": 0, + "length": 4, + "data": [0, 0, 123, 45], + "plc_connection_status": "connected", + "last_update": 1698754321.456, + "last_update_formatted": "2023-10-30 14:12:01" + } +
+
+ +
+

Single Write

+
+ POST + /api/write/// +
+

向指定区域写入数据。

+ +

路径参数

+ + + + + + + + + + + + + + + + + +
参数描述
plc_namePLC名称(如PLC1)
area_name区域名称(如DB100_Write)
offset起始偏移量(字节)
+ +

请求体

+

原始二进制数据

+ +

响应示例

+
+ { + "status": "success", + "plc_name": "PLC1", + "area_name": "DB100_Write", + "offset": 0, + "length": 4, + "plc_connection_status": "connected", + "last_update": 1698754350.789, + "last_update_formatted": "2023-10-30 14:12:30" + } +
+
+ +
+

Single Read Bool

+
+ GET + /api/read_bool//// +
+

从指定区域读取数据。

+ +

路径参数

+ + + + + + + + + + + + + + + + + + + + + +
参数描述
plc_namePLC名称(如PLC1)
area_name区域名称(如DB100_Read)
offset起始偏移量(字节)
length读取长度(字节)
+ +

响应示例

+
+ { + "status": "success", + "plc_name": "PLC1", + "area_name": "DB100_Read", + "offset": 0, + "length": 2, + "data": [0:False, 1:False], + "plc_connection_status": "connected", + "last_update": 1698754321.456, + "last_update_formatted": "2023-10-30 14:12:01" + } +
+
+ +
+

Single Write Bool

+
+ POST + /api/write_bool/// +
+

向指定区域写入数据。

+ +

路径参数

+ + + + + + + + + + + + + + + + + +
参数描述
plc_namePLC名称(如PLC1)
area_name区域名称(如DB100_Write)
offset起始偏移量(字节)
+ +

请求体

+

{0:True}

+ +

响应示例

+
+ { + "status": "success", + "plc_name": "PLC1", + "area_name": "DB100_Write", + "offset": 0, + "length": 1, + "plc_connection_status": "connected", + "last_update": 1698754350.789, + "last_update_formatted": "2023-10-30 14:12:30" + } +
+
+ +
+

Batch Read

+
+ POST + /api/batch_read +
+

批量读取多个区域的数据。

+ +

请求体

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必需描述
plc_namestringPLC名称(与配置中一致)
area_namestring区域名称(与配置中一致)
offsetnumber起始偏移量(字节),默认为0
lengthnumber读取长度(字节),不提供则读取整个区域
+ +

请求示例

+
+ [ + { + "plc_name": "PLC1", + "area_name": "DB100_Read", + "offset": 0, + "length": 4 + }, + { + "plc_name": "PLC1", + "area_name": "DB202_Params", + "offset": 10, + "length": 2 + } + ] +
+ +

响应示例

+
+ [ + { + "plc_name": "PLC1", + "area_name": "DB100_Read", + "status": "success", + "plc_connection_status": "connected", + "last_update": 1698754321.456, + "last_update_formatted": "2023-10-30 14:12:01", + "offset": 0, + "length": 4, + "data": [0, 0, 123, 45] + }, + { + "plc_name": "PLC1", + "area_name": "DB202_Params", + "status": "success", + "plc_connection_status": "connected", + "last_update": 1698754322.123, + "last_update_formatted": "2023-10-30 14:12:02", + "offset": 10, + "length": 2, + "data": [255, 0] + } + ] +
+
+ +
+

Batch Write

+
+ POST + /api/batch_write +
+

批量写入多个区域的数据。

+ +

请求体

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型必需描述
plc_namestringPLC名称(与配置中一致)
area_namestring区域名称(与配置中一致)
offsetnumber起始偏移量(字节)
dataarray要写入的数据(字节数组)
+ +

请求示例

+
+ [ + { + "plc_name": "PLC1", + "area_name": "DB100_Write", + "offset": 0, + "data": [1, 2, 3, 4] + }, + { + "plc_name": "PLC1", + "area_name": "DB202_Params", + "offset": 10, + "data": [255, 0] + } + ] +
+ +

响应示例

+
+ [ + { + "plc_name": "PLC1", + "area_name": "DB100_Write", + "status": "success", + "plc_connection_status": "connected", + "last_update": 1698754350.789, + "last_update_formatted": "2023-10-30 14:12:30", + "offset": 0, + "length": 4 + }, + { + "plc_name": "PLC1", + "area_name": "DB202_Params", + "status": "success", + "plc_connection_status": "connected", + "last_update": 1698754351.234, + "last_update_formatted": "2023-10-30 14:12:31", + "offset": 10, + "length": 2 + } + ] +
+
+ +
+

Parsed Data

+
+ GET + /api/data// +
+

获取解析后的数据(如果配置了结构)。

+ +

路径参数

+ + + + + + + + + + + + + +
参数描述
plc_namePLC名称(如PLC1)
area_name区域名称(如DB100_Read)
+ +

响应示例(配置了解析结构)

+
+ { + "parsed": { + "temperature": 25.5, + "pressure": 100, + "status": true + }, + "raw_data": [0, 0, 128, 65, 0, 100], + "status": "connected", + "plc_connection_status": "connected", + "last_update": 1698754321.456, + "last_update_formatted": "2023-10-30 14:12:01" + } +
+ +

响应示例(未配置解析结构)

+
+ { + "raw_data": [0, 0, 128, 65, 0, 100], + "status": "connected", + "plc_connection_status": "connected", + "last_update": 1698754321.456, + "last_update_formatted": "2023-10-30 14:12:01" + } +
+
+ +

Configuration API

+ +
+

Get Configuration

+
+ GET + /api/config +
+

获取当前配置。

+ +

认证要求

+

需要Basic Auth认证

+ +

响应示例

+
+ { + "plcs": [ + { + "name": "PLC1", + "ip": "192.168.0.10", + "rack": 0, + "slot": 1, + "areas": [ + { + "name": "DB100_Read", + "type": "read", + "db_number": 100, + "offset": 0, + "size": 4000, + "structure": [ + {"name": "temperature", "type": "real", "offset": 0}, + {"name": "pressure", "type": "int", "offset": 4} + ] + } + ] + } + ] + } +
+
+ +
+

Validate Configuration

+
+ POST + /api/config/validate +
+

验证配置是否有效。

+ +

认证要求

+

需要Basic Auth认证

+ +

请求体

+

要验证的配置JSON

+ +

响应示例(有效)

+
+ { + "valid": true + } +
+ +

响应示例(无效)

+
+ { + "valid": false, + "message": "Invalid configuration: 'ip' is a required property" + } +
+
+ +
+

Save Configuration

+
+ POST + /api/config +
+

保存配置。

+ +

查询参数

+ + + + + + + + + +
参数描述
reload是否立即重载配置(true/false)
+ +

认证要求

+

需要Basic Auth认证

+ +

请求体

+

要保存的配置JSON

+ +

响应示例

+
+ { + "success": true, + "message": "Configuration saved and reload requested" + } +
+
+ + + + \ No newline at end of file diff --git a/gateway/templates/config.html b/gateway/templates/config.html new file mode 100644 index 0000000..b45a010 --- /dev/null +++ b/gateway/templates/config.html @@ -0,0 +1,209 @@ + + + + PLC Gateway Configuration + + + +

PLC Gateway Configuration

+ + + +
+

Edit the configuration JSON below. Be careful with the syntax.

+ +
+ +
+ + + +
+
+ +
+ +
+

Configuration Guide

+

PLC Configuration:

+
    +
  • name: Unique name for the PLC
  • +
  • ip: IP address of the PLC
  • +
  • rack: Rack number (usually 0)
  • +
  • slot: Slot number (usually 1 for S7-1200)
  • +
+ +

Data Area Configuration:

+
    +
  • name: Name of the data area
  • +
  • type: read, write, or read_write
  • +
  • db_number: DB number (e.g., 100 for DB100)
  • +
  • offset: Starting byte offset
  • +
  • size: Size in bytes
  • +
  • structure (optional): Define how to parse the data
  • +
+ +

Example:

+
{
+  "plcs": [
+    {
+      "name": "PLC1",
+      "ip": "192.168.0.10",
+      "rack": 0,
+      "slot": 1,
+      "areas": [
+        {
+          "name": "DB100_Read",
+          "type": "read",
+          "db_number": 100,
+          "offset": 0,
+          "size": 4000,
+          "structure": [
+            {"name": "temperature", "type": "real", "offset": 0},
+            {"name": "pressure", "type": "int", "offset": 4}
+          ]
+        }
+      ]
+    }
+  ]
+}
+
+
+ + + + \ No newline at end of file diff --git a/gateway/templates/status.html b/gateway/templates/status.html new file mode 100644 index 0000000..e367e38 --- /dev/null +++ b/gateway/templates/status.html @@ -0,0 +1,126 @@ + + + + + PLC Gateway Status + + + +

PLC Gateway Status

+

Gateway running since: {{ start_time }}

+ + {% for plc_name, areas in summary.items() %} + {% set plc_status = plc_statuses.get(plc_name, "unknown") %} + {% set plc_class = { + 'connected': 'plc-connected', + 'disconnected': 'plc-disconnected' + }.get(plc_status, 'plc-never-connected') %} + +

PLC:{{plc_name}} (Status: {{plc_status}})

+ + + + + + + + + + {% for area_name, area in areas.items() %} + {% set status_class = { + 'connected': 'status-connected', + 'disconnected': 'status-disconnected', + 'never_connected': 'status-never-connected' + }.get(area.status, 'status-disconnected') %} + {% set status_text = { + 'connected': 'Connected', + 'disconnected': 'Disconnected', + 'never_connected': 'Never connected' + }.get(area.status, area.status) %} + + + + + + + + + {% endfor %} +
Area NameTypeSize (bytes)StatusPLC ConnectionLast Update
{{area_name}}{{area['type']}}{{area['size']}}{{status_text}}{{area['plc_connection_status']}}{{area['last_update']}}
+ {% endfor %} + +
+

API Endpoints

+ +
+ Single Read: GET /api/read/<plc_name>/<area_name>/<offset>/<length>
+ Example: /api/read/PLC1/DB100_Read/10/4 +
+ +
+ Single Write: POST /api/write/<plc_name>/<area_name>/<offset>
+ Body: Raw binary data
+ Example: POST /api/write/PLC1/DB100_Write/10 with 4 bytes of data +
+ +
+ Single Read_Bool: GET /api/read_bool/<plc_name>/<area_name>/<offset>/<length>
+ Example: /api/read_bool/PLC1/DB100_Read/0/2 +
+ +
+ Single Write_Bool: POST /api/write_bool/<plc_name>/<area_name>/<offset>
+ Body: Raw binary data
+ Example: POST /api/write_bool/PLC1/DB100_Write/0 +
+ +
+ Batch Read: POST /api/batch_read
+ Body: JSON array of read requests
+ Example: [{"plc_name":"PLC1", "area_name":"DB100_Read", "offset":0, "length":4}] +
+ +
+ Batch Write: POST /api/batch_write
+ Body: JSON array of write requests
+ Example: [{"plc_name":"PLC1", "area_name":"DB100_Write", "offset":0, "data":[1,2,3,4]}] +
+ +
+ Configuration: GET/POST /api/config
+ Manage gateway configuration +
+
+ + + + + + + + \ No newline at end of file