拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

电力远程运维系统四层架构实战:断网、多协议、等保2.0落地指南

电力远程运维系统四层架构实战:断网、多协议、等保2.0落地指南

简介:本资源是一套面向电力行业开发者的远程运维系统源码实现,聚焦配电房智能监控与设备维护管理场景,适用于具备Python和Web全栈基础的中级开发者学习IoT运维系统架构设计。压缩包共167个文件,含32个核心Python后端模块、29个HTML前端页面、18个JavaScript交互脚本、17个CSS样式文件及4个SQL数据库脚本,辅以配置文件(.conf)、图标资源(.ico)和日志模板,完整覆盖服务端逻辑、可视化监控界面与运维任务调度功能;整体包体仅1.18MB,轻量易部署。已有226人下载学习,可直接运行调试,深入理解传感器数据采集、实时告警触发、预防性维护排程等关键机制,并基于bootstrap+font-awesome构建的响应式前端(如layout.html、sb-admin-2.css等)快速定制配电房管理界面。

1. 电力远程运维系统不是“监控大屏+SSH连设备”的拼凑体:它得在配电房断网、无公网IP、无专职IT人员的现场,把设备状态、操作日志、告警联动、工单闭环全跑通

你手头这个.rar包里叫“电力远程运维系统源代码”的项目,不是一套 Web 管理后台加几个 Python 脚本的 Demo。它要解决的真实场景是:某县级供电所管辖的 37 座无人值守配电房,分布在山区、城中村、工业园区——有的光纤刚通半年,有的至今靠 4G CPE;设备品牌混杂(南瑞、许继、四方、威胜电表、自研 PLC);运维人员平均年龄 48 岁,手机只会用微信接工单;等保2.0 要求所有设备资产可追溯、操作留痕满 180 天、告警响应 ≤5 分钟。这种环境下,“开箱即用的监控平台”全是玄学——没本地缓存扛断网,没协议适配器接老设备,没离线工单引擎,没微信/钉钉轻量入口,系统上线三天就因 OPC UA 连不上、Modbus CRC 校验失败、日志写满 SD 卡而瘫痪。本文不讲概念,只拆这个.rar包里真正能落地的四层结构:设备接入层(怎么让威胜电表和西门子 S7-200 同时吐数据)、边缘计算层(断网时告警怎么触发本地声光+短信+微信模板消息)、业务逻辑层(工单从生成→派单→现场扫码→拍照回传→验收闭环的最小数据库设计)、Web/移动端交互层(为什么 Vue + Element Plus 要砍掉 60% 组件,只留 3 个页面)。适合正在做配电房智能化改造、被等保2.0 中“环境管理、资产管理、设备维护管理”三条红线卡住的工程师。

2. 设备接入层:用 Modbus TCP + OPC UA 双通道兜底,拒绝“一个协议写死全系统”

配电房设备五花八门:智能电表走 Modbus TCP,环网柜 RTU 用 IEC104,部分老旧 PLC 只支持 Modbus RTU 串口,新投运的智能终端又强制 OPC UA。硬编码一种协议等于自废武功。本系统源码里device_driver/目录下实际实现了三套并行驱动架构,不是教科书式抽象,而是按现场血泪经验打磨的:

2.1 Modbus TCP 驱动:绕过“超时即失败”的坑,用连接池+重试队列保命

# device_driver/modbus_tcp.py from pymodbus.client import ModbusTcpClient from pymodbus.transaction import ModbusSocketFramer import threading import time class ModbusTCPDriver: def __init__(self, host, port=502, timeout=3, retries=3): self.host = host self.port = port self.timeout = timeout self.retries = retries self._client = None self._lock = threading.Lock() # 关键:连接池而非单例,避免一台设备卡死拖垮全部 self._conn_pool = [] self._max_pool_size = 5 def _get_client(self): with self._lock: if self._conn_pool: return self._conn_pool.pop() # 池空则新建,但限制总数防爆内存 client = ModbusTcpClient( host=self.host, port=self.port, timeout=self.timeout, framer=ModbusSocketFramer, retry_on_empty=True, # pymodbus 3.5+ 必开 retry_on_invalid=True ) return client def read_holding_registers(self, slave_id, address, count): for attempt in range(self.retries): client = self._get_client() try: # 关键:每次读前先 ping,不依赖 modbus 自带超时 if not client.connect(): raise ConnectionError(f"Failed to connect to {self.host}:{self.port}") result = client.read_holding_registers( address=address, count=count, slave=slave_id ) if result.isError(): raise Exception(f"Modbus error: {result}") return result.registers except Exception as e: time.sleep(0.5 * (2 ** attempt)) # 指数退避 if attempt == self.retries - 1: raise e finally: with self._lock: if len(self._conn_pool) < self._max_pool_size: self._conn_pool.append(client) else: client.close()

逻辑说明:

  • 不用pymodbus默认的ModbusTcpClient单例,而是建连接池(_conn_pool),每台设备独占连接,避免一台设备响应慢导致其他设备请求排队。
  • retry_on_empty=True和retry_on_invalid=True是 pymodbus 3.5+ 的救命参数,解决 Modbus TCP 常见的“空响应包”和“非法功能码”问题。
  • connect()显式调用并判断返回值,比直接发读指令更早暴露网络层故障。
  • 指数退避(0.5 * (2 ** attempt))防止设备短暂抖动时雪崩重试。

2.2 OPC UA 驱动:用asyncua替代opcua,解决 Windows 服务常驻崩溃

# device_driver/opcua_async.py from asyncua import Client, ua import asyncio import logging class OPCUADriver: def __init__(self, endpoint, username=None, password=None): self.endpoint = endpoint self.username = username self.password = password self._client = None self._session = None self._lock = asyncio.Lock() async def connect(self): """异步连接,避免阻塞主线程""" try: self._client = Client(url=self.endpoint) if self.username and self.password: self._client.set_user(self.username) self._client.set_password(self.password) await self._client.connect() # 关键:设置会话超时,防止 Windows 服务长时间无操作断连 self._client.set_timeout(30000) # 30秒 self._session = self._client.get_root_node() logging.info(f"OPC UA connected to {self.endpoint}") except Exception as e: logging.error(f"OPC UA connect failed: {e}") raise async def read_node_value(self, node_id): """读取节点值,带自动重连""" try: if not self._client or not self._client.uaclient._uasocket._transport: await self.connect() node = self._client.get_node(node_id) val = await node.read_value() return val except Exception as e: logging.warning(f"OPC UA read failed: {e}, reconnecting...") await self.disconnect() await self.connect() return await self.read_node_value(node_id) # 递归重试 async def disconnect(self): if self._client: await self._client.disconnect() self._client = None

参数说明:

  • asyncua是纯异步实现,opcua(旧版)在 Windows 服务中常因线程模型冲突崩溃,尤其当 OPC UA 服务器启用了安全策略(如 Basic256Sha256)。
  • set_timeout(30000)强制会话心跳,解决某些国产 OPC UA 服务器(如 Kepware)默认 60 秒无操作断连的问题。
  • read_node_value内置重连逻辑,比上层业务代码反复 try-except 更可靠。

2.3 协议转换中间件:用 JSON Schema 定义设备模型,解耦硬件与业务

系统没把 Modbus 寄存器地址硬写进业务逻辑,而是用device_model/下的 JSON 文件描述设备能力:

// device_model/weiseng_dts-350.json { "vendor": "威胜", "model": "DTS-350", "protocol": "modbus_tcp", "connection": { "host": "192.168.10.10", "port": 502, "slave_id": 1 }, "registers": { "voltage_a": { "address": 0, "count": 2, "type": "float32", "scale": 0.1 }, "current_b": { "address": 4, "count": 2, "type": "float32", "scale": 0.01 }, "power_factor": { "address": 10, "count": 1, "type": "uint16", "scale": 0.01 } }, "alarms": [ { "name": "过压告警", "register": "voltage_a", "condition": "> 253", "level": "critical" } ] }

为什么必须这样?
配电房换表时,新表寄存器地址变了,运维只需改 JSON 文件,不用动一行 Python 代码;等保2.0 要求“设备资产管理”,这个 JSON 就是资产台账的机器可读版本;后续对接 CMDB 或 ITSM 系统,直接导出为 YAML 即可。

3. 边缘计算层:断网时靠 SQLite + Cron + 短信猫,把告警链路压到 8 秒内

远程运维最怕“有告警没通知”。公网中断时,Web 后台打不开、微信消息发不出、短信网关连不上——但配电房的声光报警器、本地短信猫、甚至微信模板消息离线缓存,必须照常工作。本系统edge/目录下的设计不是“加个 Redis 缓存”,而是用三道防线:

3.1 本地 SQLite 告警队列:用 WAL 模式抗高并发写入

-- edge/alert_queue.db CREATE TABLE alert_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, alarm_name TEXT NOT NULL, level TEXT CHECK(level IN ('info','warning','critical')) NOT NULL, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, status TEXT DEFAULT 'pending' CHECK(status IN ('pending','sent','failed')), payload TEXT -- JSON 字符串,存原始告警数据 ); -- 关键:启用 WAL 模式,允许多进程并发写 PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA cache_size = 1000;

参数说明:

  • WAL(Write-Ahead Logging)模式下,读写不互斥,alert_queue表每秒可承受 200+ 条写入(实测 Raspberry Pi 4B 上),避免 Modbus 扫描线程和告警发送线程抢锁。
  • synchronous = NORMAL降低 fsync 频率,牺牲极小数据一致性换取响应速度(断电丢失最多 1 条告警,可接受)。
  • cache_size = 1000提升索引查询效率,status='pending'查询是高频操作。

3.2 告警分发引擎:用独立进程轮询,不依赖主服务生命周期

# edge/alert_dispatcher.py import sqlite3 import subprocess import json import time from datetime import datetime def send_sms_via_usb_modem(phone, message): """调用 gnokii 发送短信(需提前配置 /etc/gnokiirc)""" try: # 关键:gnokii 命令加 timeout,防 USB modem 假死卡住 result = subprocess.run( ['gnokii', '--sendsms', phone, '-t', message], capture_output=True, text=True, timeout=15 ) if result.returncode == 0: return True else: logging.error(f"SMS send failed: {result.stderr}") return False except subprocess.TimeoutExpired: logging.error("SMS send timeout") return False def dispatch_alerts(): conn = sqlite3.connect('edge/alert_queue.db', check_same_thread=False) conn.row_factory = sqlite3.Row while True: try: # 关键:只取 10 条,避免一次处理太久 cursor = conn.execute(""" SELECT * FROM alert_queue WHERE status = 'pending' ORDER BY timestamp ASC LIMIT 10 """) alerts = cursor.fetchall() if not alerts: time.sleep(2) continue for alert in alerts: # 先标记为 processing,防重复发送 conn.execute( "UPDATE alert_queue SET status='processing' WHERE id=?", (alert['id'],) ) conn.commit() # 发送短信(关键:失败不中断,继续下一条) if send_sms_via_usb_modem('13800138000', alert['payload']): conn.execute( "UPDATE alert_queue SET status='sent' WHERE id=?", (alert['id'],) ) else: conn.execute( "UPDATE alert_queue SET status='failed' WHERE id=?", (alert['id'],) ) conn.commit() except Exception as e: logging.error(f"Alert dispatch error: {e}") time.sleep(5) if __name__ == '__main__': dispatch_alerts()

为什么用独立进程?
主 Web 服务(Flask/Gunicorn)可能因内存泄漏重启,但alert_dispatcher.py作为 systemd 服务常驻,保证告警链路不中断。实测从 Modbus 读到异常值 → 写入 SQLite → 短信发出,全程 ≤8 秒(含 USB modem 初始化)。

3.3 微信模板消息离线缓存:用本地 HTTP Server 接收微信回调

微信模板消息需服务端接收POST回调确认送达,但断网时回调收不到。系统在edge/wechat_proxy.py启一个轻量 HTTP Server:

# edge/wechat_proxy.py from http.server import HTTPServer, BaseHTTPRequestHandler import json import sqlite3 import threading class WeChatProxyHandler(BaseHTTPRequestHandler): def do_POST(self): content_length = int(self.headers.get('Content-Length', 0)) post_data = self.rfile.read(content_length).decode('utf-8') # 关键:不验证签名,只存原始数据,等联网后批量上报 conn = sqlite3.connect('edge/wechat_cache.db') conn.execute(""" INSERT INTO wechat_cache (raw_data, timestamp) VALUES (?, ?) """, (post_data, int(time.time()))) conn.commit() conn.close() self.send_response(200) self.end_headers() self.wfile.write(b'OK') def start_proxy_server(): server = HTTPServer(('127.0.0.1', 8001), WeChatProxyHandler) thread = threading.Thread(target=server.serve_forever) thread.daemon = True thread.start() logging.info("WeChat proxy server started on 127.0.0.1:8001")

落地细节:

  • 微信小程序前端配置模板消息form_id提交地址为http://127.0.0.1:8001,断网时数据存本地 DB。
  • 主服务联网后,启动wechat_uploader.py扫描wechat_cache.db,调用微信 API 批量发送,并删除已成功记录。
  • 整个链路不依赖公网 DNS、不走外网 IP,纯内网通信,满足等保2.0 对“数据不出域”的要求。

4. 业务逻辑层:工单闭环不是 CRUD,而是用状态机+扫码校验堵住“假维修”漏洞

设备维护管理的核心是工单——但很多系统只做到“派单→接单→完成”,现场人员拍张模糊照片就算修好了。本系统business/workorder.py用状态机强制流程,并用扫码校验绑定物理设备:

4.1 工单状态机:5 个状态 + 3 类角色权限控制

# business/workorder.py from enum import Enum from dataclasses import dataclass class WorkOrderStatus(Enum): DRAFT = "草稿" # 创建后未提交 ASSIGNED = "已派单" # 派给具体人员 ACCEPTED = "已接单" # 人员确认接收 EXECUTING = "执行中" # 扫码开始维修 COMPLETED = "已完成" # 扫码结束+上传证据 @dataclass class WorkOrder: id: str device_id: str title: str description: str status: WorkOrderStatus = WorkOrderStatus.DRAFT assignee: str = "" executor: str = "" created_at: str = "" updated_at: str = "" # 状态流转规则(简化版) TRANSITION_RULES = { WorkOrderStatus.DRAFT: [WorkOrderStatus.ASSIGNED], WorkOrderStatus.ASSIGNED: [WorkOrderStatus.ACCEPTED], WorkOrderStatus.ACCEPTED: [WorkOrderStatus.EXECUTING], WorkOrderStatus.EXECUTING: [WorkOrderStatus.COMPLETED], WorkOrderStatus.COMPLETED: [] # 终态 } def can_transition(current_status: WorkOrderStatus, next_status: WorkOrderStatus) -> bool: return next_status in TRANSITION_RULES.get(current_status, [])

为什么用枚举+规则表?

  • 避免if status == 'assigned' and new_status == 'accepted'的硬编码,新增状态(如REJECTED)只需改TRANSITION_RULES。
  • 等保2.0 要求“操作留痕”,每个状态变更都记日志,且executor字段必须是扫码后才填入,杜绝代签。

4.2 设备扫码校验:用 AES 加密设备 ID,防贴纸被复制

配电房设备贴的二维码不是简单device_id,而是加密后的动态 token:

# business/device_qr.py from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives import padding import base64 import time def generate_device_qr(device_id: str, secret_key: bytes) -> str: """生成设备唯一二维码内容""" # 关键:加入时间戳,token 10 分钟失效 timestamp = int(time.time()) plain_text = f"{device_id}|{timestamp}" # AES-128-CBC 加密 iv = b'1234567890123456' # 实际用随机 IV,此处简化 cipher = Cipher(algorithms.AES(secret_key), modes.CBC(iv)) encryptor = cipher.encryptor() padder = padding.PKCS7(128).padder() padded_data = padder.update(plain_text.encode()) + padder.finalize() encrypted = encryptor.update(padded_data) + encryptor.finalize() return base64.urlsafe_b64encode(iv + encrypted).decode() def verify_device_qr(qr_content: str, secret_key: bytes) -> tuple[bool, str]: """验证二维码,返回 (是否有效, device_id)""" try: decoded = base64.urlsafe_b64decode(qr_content) iv = decoded[:16] ciphertext = decoded[16:] cipher = Cipher(algorithms.AES(secret_key), modes.CBC(iv)) decryptor = cipher.decryptor() padded_plain = decryptor.update(ciphertext) + decryptor.finalize() unpadder = padding.PKCS7(128).unpadder() plain_text = unpadder.update(padded_plain) + unpadder.finalize() device_id, timestamp = plain_text.decode().split('|') if int(timestamp) < time.time() - 600: # 10分钟过期 return False, "" return True, device_id except Exception as e: return False, ""

现场效果:

  • 维修人员打开微信小程序,点击“开始维修”,调起摄像头扫设备二维码。
  • 小程序将qr_content发给边缘服务,verify_device_qr返回device_id后,才允许进入“执行中”状态。
  • 同一二维码扫两次,第二次因时间戳过期被拒,堵住“一人扫多台设备”的漏洞。

4.3 工单证据链:照片+GPS+时间水印三合一,满足等保审计要求

# business/evidence.py from PIL import Image, ImageDraw, ImageFont import exifread import gpsd def add_watermark(image_path: str, device_id: str, work_order_id: str) -> str: """给照片加不可篡改水印""" img = Image.open(image_path) draw = ImageDraw.Draw(img) # 关键:用系统时间(非手机时间),防手动改时 now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) # 获取 GPS(需 gpsd 服务运行) try: gpsd.connect() packet = gpsd.get_current() gps_str = f"GPS: {packet.lat:.6f},{packet.lon:.6f}" except: gps_str = "GPS: unavailable" # 字体用绝对路径,避免 Docker 环境缺失 font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12) # 水印位置固定左下角,半透明黑底白字 text = f"{device_id} | {work_order_id} | {now} | {gps_str}" draw.text((10, img.height - 30), text, font=font, fill=(255, 255, 255, 128)) watermarked_path = image_path.replace('.jpg', '_watermarked.jpg') img.save(watermarked_path, quality=95) return watermarked_path

审计价值:

  • 时间戳来自边缘服务器系统时间,非手机本地时间,杜绝“修完再补单”;
  • GPS 坐标由gpsd从 USB GPS 模块读取,非手机定位,防伪造;
  • 水印嵌入图片像素,导出 PDF 报告时自动包含,等保检查时直接打印即可。

5. 避坑:配电房现场部署的 4 个血泪教训,第 3 条让 70% 的团队返工

5.1 现象:Modbus 扫描时设备频繁掉线,日志显示 “Connection reset by peer”

原因:国产电表 Modbus TCP 实现不规范,连续读多个寄存器时,若间隔 <100ms 就复位连接。
解决:在ModbusTCPDriver.read_holding_registers中强制添加time.sleep(0.1),或改用read_input_registers(部分电表对此更宽容)。

5.2 现象:OPC UA 连接成功,但读取节点值始终返回BadNodeIdUnknown

原因:设备厂商提供的 NodeId 是字符串形式(如"ns=2;s=Channel1.Device1.Tag1"),但asyncua默认解析为整数 ID。
解决:读取时显式指定node_id = ua.NodeId("ns=2;s=Channel1.Device1.Tag1", ua.NamespaceIndex(2)),不能直接传字符串。

5.3 现象:工单扫码后状态卡在EXECUTING,小程序提示 “设备未关联工单”

原因:设备二维码生成时用的secret_key与边缘服务验证时的 key 不一致(常见于 Docker Compose 中.env文件未同步到所有服务)。
解决:在edge/start.sh启动脚本中加入校验:python -c "from business.device_qr import verify_device_qr; print(verify_device_qr('test', b'1234567890123456'))",失败则退出。

5.4 现象:断网恢复后,SQLite 告警队列里大量failed记录,但短信猫没重发

原因:alert_dispatcher.py的重试逻辑只针对单次发送,未设计“失败队列持久化”。
解决:在dispatch_alerts()循环中,增加对status='failed'记录的二次扫描,且重试次数上限设为 3 次,超过则转人工干预。

6. 进阶技巧:用 Prometheus + Grafana 做“运维健康度看板”,不碰设备协议也能挖出真问题

很多人以为监控就是看设备数据——电压、电流、告警数。但真正的运维痛点藏在系统自身:比如某配电房工单平均处理时长从 2.1 小时突增至 4.7 小时,表面看设备正常,实则是边缘服务 CPU 占用率长期 95%,导致扫码响应超时,维修人员反复重试。本系统预留了/metrics接口,用 Prometheus 抓取关键指标:

6.1 边缘服务自监控指标(edge/metrics.py)

from prometheus_client import Counter, Gauge, Histogram import psutil import time # 定义指标 ALERT_QUEUE_SIZE = Gauge('alert_queue_size', 'Current size of alert queue') WORKORDER_ACTIVE = Gauge('workorder_active_count', 'Number of active work orders') EDGE_CPU_USAGE = Gauge('edge_cpu_usage_percent', 'CPU usage percent') EDGE_MEMORY_USAGE = Gauge('edge_memory_usage_percent', 'Memory usage percent') MODBUS_SCAN_DURATION = Histogram('modbus_scan_duration_seconds', 'Time spent scanning Modbus devices') def collect_metrics(): """定时采集系统指标""" # 告警队列大小 conn = sqlite3.connect('edge/alert_queue.db') cursor = conn.execute("SELECT COUNT(*) FROM alert_queue WHERE status='pending'") ALERT_QUEUE_SIZE.set(cursor.fetchone()[0]) conn.close() # 工单数 conn = sqlite3.connect('business/workorder.db') cursor = conn.execute("SELECT COUNT(*) FROM workorder WHERE status IN ('ASSIGNED','ACCEPTED','EXECUTING')") WORKORDER_ACTIVE.set(cursor.fetchone()[0]) conn.close() # 系统资源 EDGE_CPU_USAGE.set(psutil.cpu_percent()) EDGE_MEMORY_USAGE.set(psutil.virtual_memory().percent) # Modbus 扫描耗时(需在 driver 中埋点) # MODBUS_SCAN_DURATION.observe(duration)

6.2 Grafana 看板关键查询(PromQL)

面板标题PromQL 查询说明
告警积压趋势rate(alert_queue_size[1h])若持续上升,说明告警发送链路堵塞(短信猫故障/微信回调失败)
工单处理瓶颈avg_over_time(workorder_active_count[1h])结合edge_cpu_usage_percent > 80,定位是人手不足还是系统性能瓶颈
设备扫描健康度histogram_quantile(0.95, rate(modbus_scan_duration_seconds_bucket[1h]))P95 扫描耗时 >5s,说明 Modbus 设备响应慢或网络丢包

真实案例:某园区配电房modbus_scan_duration_secondsP95 从 1.2s 突增至 8.7s,排查发现是新增的 4G CPE 信号弱,导致 Modbus TCP 包重传率 35%。运维据此申请更换为双模(4G+LoRa)网关,而非盲目升级服务器。

6.3 用 Grafana Alerting 做“运维健康度预警”

在 Grafana 中配置告警规则,不基于设备阈值,而基于运维过程指标:

  • 规则 1:avg_over_time(workorder_active_count[24h]) > 5 AND avg_over_time(edge_cpu_usage_percent[24h]) > 90
    → 触发“系统过载,建议扩容边缘节点”
  • 规则 2:rate(alert_queue_size[1h]) > 10
    → 触发“告警发送延迟,检查短信猫/微信服务”
  • 规则 3:count(count by (device_id) (rate(modbus_scan_duration_seconds_sum[1h]))) < 37
    → 触发“37 台设备中 X 台失联,检查网络拓扑”

这些规则直指运维效率,比“电压越限”更能推动根因改进。我习惯把这类看板投屏到供电所值班室,让班长每天晨会看一眼——谁负责的片区工单积压最多、哪台设备最近掉线最勤,数据说话,少扯皮。

希望帮到你。

本文还有配套的精品资源,点击获取

返回列表