From 45deb5131dc01dc932af6c39b4713b7637f0cfdc Mon Sep 17 00:00:00 2001 From: chuyiwen Date: Sat, 26 Jul 2025 02:46:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E6=9C=AC=E6=A1=86=E6=9E=B6=E5=AE=8C?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config.json | 66 ++ config/config.json.bak | 66 ++ .../__pycache__/api_server.cpython-313.pyc | Bin 0 -> 28078 bytes .../__pycache__/cache_manager.cpython-313.pyc | Bin 0 -> 13003 bytes .../__pycache__/config_loader.cpython-313.pyc | Bin 0 -> 733 bytes .../config_manager.cpython-313.pyc | Bin 0 -> 5440 bytes .../config_validator.cpython-313.pyc | Bin 0 -> 2501 bytes .../__pycache__/plc_manager.cpython-313.pyc | Bin 0 -> 2081 bytes .../__pycache__/snap7_client.cpython-313.pyc | Bin 0 -> 5444 bytes gateway/api_server.py | 565 ++++++++++++++++++ gateway/cache_manager.py | 277 +++++++++ gateway/config_loader.py | 10 + gateway/config_manager.py | 87 +++ gateway/config_validator.py | 82 +++ gateway/data_parser.py | 54 ++ gateway/main.py | 109 ++++ gateway/plc_manager.py | 42 ++ gateway/snap7_client.py | 108 ++++ 18 files changed, 1466 insertions(+) create mode 100644 config/config.json create mode 100644 config/config.json.bak create mode 100644 gateway/__pycache__/api_server.cpython-313.pyc create mode 100644 gateway/__pycache__/cache_manager.cpython-313.pyc create mode 100644 gateway/__pycache__/config_loader.cpython-313.pyc create mode 100644 gateway/__pycache__/config_manager.cpython-313.pyc create mode 100644 gateway/__pycache__/config_validator.cpython-313.pyc create mode 100644 gateway/__pycache__/plc_manager.cpython-313.pyc create mode 100644 gateway/__pycache__/snap7_client.cpython-313.pyc create mode 100644 gateway/api_server.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 diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..fb847c4 --- /dev/null +++ b/config/config.json @@ -0,0 +1,66 @@ +{ + "plcs": [ + { + "name": "PLC1", + "ip": "192.168.0.100", + "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 + }, + { + "name": "status", + "type": "bool", + "offset": 6, + "bit": 0 + } + ] + }, + { + "name": "DB100_Write", + "type": "write", + "db_number": 100, + "offset": 4000, + "size": 5000 + }, + { + "name": "DB202_Params", + "type": "read_write", + "db_number": 202, + "offset": 0, + "size": 2000 + } + ] + }, + { + "name": "PLC2", + "ip": "192.168.0.101", + "rack": 0, + "slot": 1, + "areas": [ + { + "name": "DB100_Read", + "type": "read", + "db_number": 100, + "offset": 0, + "size": 4000 + } + ] + } + ] +} \ No newline at end of file diff --git a/config/config.json.bak b/config/config.json.bak new file mode 100644 index 0000000..fb847c4 --- /dev/null +++ b/config/config.json.bak @@ -0,0 +1,66 @@ +{ + "plcs": [ + { + "name": "PLC1", + "ip": "192.168.0.100", + "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 + }, + { + "name": "status", + "type": "bool", + "offset": 6, + "bit": 0 + } + ] + }, + { + "name": "DB100_Write", + "type": "write", + "db_number": 100, + "offset": 4000, + "size": 5000 + }, + { + "name": "DB202_Params", + "type": "read_write", + "db_number": 202, + "offset": 0, + "size": 2000 + } + ] + }, + { + "name": "PLC2", + "ip": "192.168.0.101", + "rack": 0, + "slot": 1, + "areas": [ + { + "name": "DB100_Read", + "type": "read", + "db_number": 100, + "offset": 0, + "size": 4000 + } + ] + } + ] +} \ No newline at end of file diff --git a/gateway/__pycache__/api_server.cpython-313.pyc b/gateway/__pycache__/api_server.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b99853a74fed72df5abd8a23170dc8a9c05b16ab GIT binary patch literal 28078 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 literal 0 HcmV?d00001 diff --git a/gateway/__pycache__/cache_manager.cpython-313.pyc b/gateway/__pycache__/cache_manager.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0378d6a470a0dd1c5b23fd4b90b5f0894d56e5c7 GIT binary patch literal 13003 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 E--~ENivR!s 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..ce1d239294714a10d84a302ac7d84f57783fa7fc GIT binary patch literal 733 zcmYjP&ubGw6n?Y2CQa6)rh*5R?pUN4tPv@tP&8Q7UJ9vX$ti^rlHD{h?u6M5n&Lqa z(N-j;!Q6Stp7nop)l=1F#hOG2u1MZn`AfoX6C(b-n{qCyqW38Vo^YO^Xk!h ziU53-lRy+7^d}K)g9#>C20pZe6H_&n(@-T`DU+mjD1$RaQ4b{> zFb58_*<=4re3*kdpYMY2Da8Tu;ULl^If%oo5=2=f9|_iSU@CL|J_TJomDOvi0XE)1V*>fUZSDN*bPgXs|IvB< z{l$~sySE#YH<)8r$_-xdD$JpFrDjowdDLbNr?fx?;hAJuD9jaFz2L2jkafS}dag(; zVPuAROET^j=Zp)D)h$P8RhOZzu|g@S2)#I>58_65>iX`P z-B_1qKE+qMV=KK#?CG8M?B|HFbLC+CWUIN=Y$u*Q_^m>e?nMmje_S2;-k(}D)DK2< z@lrTRHYSE!ULKm1EG(&V`BYr~hrv-^LLVV;e0mULSMRb?qh_VKylweA+!LsNsf3Vy P(Dxy7q=m>?o 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 NjpJLmCuwAz{|0_ys67Ax literal 0 HcmV?d00001 diff --git a/gateway/api_server.py b/gateway/api_server.py new file mode 100644 index 0000000..61689b9 --- /dev/null +++ b/gateway/api_server.py @@ -0,0 +1,565 @@ +from flask import Flask, jsonify, request, render_template_string, Response +import threading +import time +import json +import logging +from functools import wraps +from config_manager import ConfigManager + +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.setup_routes() + self.auth_enabled = True # 可通过配置关闭认证 + self.username = "admin" + self.password = "admin123" # 实际应用中应从安全存储获取 + self.start_time = time.strftime("%Y-%m-%d %H:%M:%S") + self.logger = logging.getLogger("APIServer") + + 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] + status = area["status"] + + summary[plc_name][area_name] = { + "status": 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(): + html += f"

PLC: {plc_name}

" + html += """ + + + + + + + + + """ + + for area_name, area in areas.items(): + status_class = "" + if area["status"] == "connected": + status_class = "status-connected" + elif area["status"] == "disconnected": + status_class = "status-disconnected" + else: + status_class = "status-error" + + html += f""" + + + + + + + + """ + + html += "
Area NameTypeSize (bytes)StatusLast Update
{area_name}{area['type']}{area['size']}{area['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 +
+ +
+ 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(): + """获取系统状态信息""" + 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()) + }) + + # =========================== + # 配置管理相关路由 + # =========================== + @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/read////", methods=["GET"], endpoint="single_read") + def single_read(plc_name, area_name, offset, length): + """从指定区域读取数据""" + data, error = self.cache_manager.read_area(plc_name, area_name, offset, length) + if error: + return jsonify({"status": "error", "message": error}), 400 + return jsonify({ + "status": "success", + "plc_name": plc_name, + "area_name": area_name, + "offset": offset, + "length": length, + "data": list(data) + }) + + # 单个写入接口 + @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"}), 400 + + success, error = self.cache_manager.write_area(plc_name, area_name, offset, data) + if error: + return jsonify({"status": "error", "message": error}), 400 + return jsonify({ + "status": "success", + "plc_name": plc_name, + "area_name": area_name, + "offset": offset, + "length": len(data) + }) + + # 批量读取接口 + @self.app.route("/api/batch_read", methods=["POST"], endpoint="batch_read") + def batch_read(): + """批量读取多个区域的数据""" + try: + requests = request.json + if not isinstance(requests, list): + return jsonify({"status": "error", "message": "Request must be a JSON array"}), 400 + results = self.cache_manager.batch_read(requests) + return jsonify(results) + except Exception as e: + return jsonify({"status": "error", "message": str(e)}), 400 + + # 批量写入接口 + @self.app.route("/api/batch_write", methods=["POST"], endpoint="batch_write") + def batch_write(): + """批量写入多个区域的数据""" + try: + requests = request.json + if not isinstance(requests, list): + return jsonify({"status": "error", "message": "Request must be a JSON array"}), 400 + results = self.cache_manager.batch_write(requests) + return jsonify(results) + except Exception as e: + return jsonify({"status": "error", "message": str(e)}), 400 + + # 区域状态检查 + @self.app.route("/api/status//", methods=["GET"], endpoint="area_status") + def area_status(plc_name, area_name): + """获取区域状态""" + return jsonify(self.cache_manager.get_area_status(plc_name, area_name)) + + # 获取解析后的数据 + @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..32f8fff --- /dev/null +++ b/gateway/cache_manager.py @@ -0,0 +1,277 @@ +import threading +import time +import logging + +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.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] = {} + + 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: + try: + for plc in self.config["plcs"]: + plc_name = plc["name"] + client = self.plc_manager.get_plc(plc_name) + + 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"]) + with self.lock: + self.cache[plc_name][name]["data"] = bytearray(data) + self.cache[plc_name][name]["status"] = "connected" + self.last_update[plc_name][name] = time.time() + except Exception as e: + with self.lock: + self.cache[plc_name][name]["status"] = "disconnected" + self.logger.warning(f"PLC {plc_name} area {name} disconnected: {e}") + + 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_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] + summary[plc_name][area_name] = { + "status": area["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"} + return { + "status": area["status"], + "last_update": self.last_update[plc_name][area_name], + "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) + if not area: + return None, "Area not found" + + if offset + length > area["size"]: + return None, "Offset out of bounds" + + client = self.plc_manager.get_plc(plc_name) + try: + data = client.read_db(area["db_number"], area["offset"] + offset, length) + # 更新缓存中的这部分数据 + for i in range(length): + area["data"][offset + i] = data[i] + self.last_update[plc_name][area_name] = time.time() + area["status"] = "connected" + return data, None + except Exception as e: + area["status"] = "disconnected" + self.logger.error(f"Read failed for {plc_name}/{area_name}: {e}") + return None, f"Read failed: {str(e)}" + + 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" + + if area["type"] not in ["write", "read_write"]: + return False, "Area is read-only" + + if offset + len(data) > area["size"]: + return False, "Offset out of bounds" + + client = self.plc_manager.get_plc(plc_name) + 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] + self.last_update[plc_name][area_name] = time.time() + area["status"] = "connected (last write)" + return True, None + else: + area["status"] = "disconnected" + return False, "Write failed" + except Exception as e: + area["status"] = "disconnected" + self.logger.error(f"Write failed for {plc_name}/{area_name}: {e}") + return False, f"Write failed: {str(e)}" + + 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) + + area = self.cache.get(plc_name, {}).get(area_name) + if not area: + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "error", + "message": "Area not found" + }) + continue + + # 如果未指定length,读取整个区域 + if length is None: + length = area["size"] - offset + + data, error = self.read_area(plc_name, area_name, offset, length) + if error: + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "error", + "message": error + }) + else: + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "success", + "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"]) + + success, error = self.write_area(plc_name, area_name, offset, data) + if error: + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "error", + "message": error, + "offset": offset + }) + else: + results.append({ + "plc_name": plc_name, + "area_name": area_name, + "status": "success", + "offset": offset, + "length": len(data) + }) + return results + + 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"} + + structure = area.get("structure", []) + if structure: + return parse_data(area["data"], structure) + else: + return { + "raw_data": list(area["data"]), + "status": area["status"], + "last_update": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.last_update[plc_name][area_name])) + if self.last_update[plc_name][area_name] > 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..487edf5 --- /dev/null +++ b/gateway/config_validator.py @@ -0,0 +1,82 @@ +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}, + "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 + def validate_config(config): + """验证配置是否符合规范""" + 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..38890b3 --- /dev/null +++ b/gateway/data_parser.py @@ -0,0 +1,54 @@ +from struct import unpack + +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..94c5218 --- /dev/null +++ b/gateway/snap7_client.py @@ -0,0 +1,108 @@ +import snap7 +import logging +from threading import Lock +import time + +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 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(): + self.connected = True + self.retry_count = 0 # 重置重试计数 + self.logger.info(f"Connected to PLC {self.ip}") + return True + else: + self.connected = False + self.logger.warning(f"Connection to {self.ip} established but not verified") + 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: 读取的数据 + """ + if not self.connected and not self.connect(): + self.logger.warning(f"Read failed: not connected to {self.ip}") + return b'\x00' * size + + try: + with self.lock: + data = self.client.db_read(db_number, offset, size) + return data + except Exception as e: + self.logger.error(f"Read DB{db_number} error: {e}") + self.connected = False + return b'\x00' * size + + 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: + 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 \ No newline at end of file