mtc/mqttc.py

421 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import sys
import re
import paho.mqtt.client as mqtt
import json
import logging
from logging.handlers import RotatingFileHandler
from sqlalchemy import create_engine, Engine, text
from sqlalchemy.exc import IntegrityError
from datetime import datetime, timedelta
from dateutil import tz
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from threading import Thread, Lock
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import traceback
import queue
CUR_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, CUR_DIR)
import conf
import time
LOG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mqttc.log')
logging.basicConfig(level=getattr(logging, conf.LOG_LEVEL.upper(), logging.INFO),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout), # Log to console
RotatingFileHandler(LOG_FILE, maxBytes=5*1034*1024, backupCount=1, encoding="utf-8"), # Log to file
])
logger = logging.getLogger(__name__)
engine:Engine = None
# {table_name: {mpoint_id: last_timex}} 按表分桶,避免跨表互相抑制
mpoint_dict: dict = {}
SHANGHAI = tz.gettz('Asia/Shanghai')
# topic -> 目标表名 映射
TOPIC_TABLE_MAP = {
conf.MQTT_TOPIC: 'mplogx',
conf.MQTT_TOPIC_zl: 'mplogx_xzzl',
conf.MQTT_TOPIC_tl: 'mplogx_tlxn',
conf.MQTT_TOPIC_1: 'mplogx_hknf_l1',
conf.MQTT_TOPIC_2: 'mplogx_hknf_l2',
conf.MQTT_TOPIC_3: 'mplogx_hknf_l3',
conf.MQTT_TOPIC_kio: 'mplogx_wengfu',
}
# KIO datachange 格式(偏移使能)的 topic走独立解析逻辑
KIO_TOPICS = {conf.MQTT_TOPIC_kio}
# 各表 INSERT 用的值列(除 timex, mpoint_id 外);未列出的表用默认两列
# mplogx_wengfu(KIO) 按值类型分列存储bool->val_bool, int->val_int, float->val_float, 文本->val_str
TABLE_VALUE_COLS = {
'mplogx_mqtt': ['val_float', 'val_str', 'val_q'],
'mplogx_wengfu': ['val_float', 'val_str', 'val_q', 'val_bool', 'val_int'],
}
DEFAULT_VALUE_COLS = ['val_float', 'val_str']
# topic -> 独立队列,每个 topic 一个 worker 线程消费
msg_queues: dict = {topic: queue.Queue() for topic in TOPIC_TABLE_MAP}
# 错误邮件发送限流:最多 1 小时发一封
EMAIL_INTERVAL = 3600
_last_email_time = 0
_email_lock = Lock()
def send_error_email(message, subject='hfnf_mqtt', ):
now = time.time()
with _email_lock:
global _last_email_time
if now - _last_email_time < EMAIL_INTERVAL:
logger.info(f"距上次报警邮件不足 {EMAIL_INTERVAL}s跳过本次发送: {message}")
return
_last_email_time = now
msg = MIMEMultipart()
msg['From'] = conf.EMAIL_HOST_USER
msg['To'] = conf.EMAIL_HOST_USER
msg['Subject'] = subject
msg.attach(MIMEText(message, 'plain'))
server = smtplib.SMTP(conf.EMAIL_HOST, conf.EMAIL_PORT)
server.starttls()
server.login(conf.EMAIL_HOST_USER, conf.EMAIL_HOST_PASSWORD)
server.sendmail(conf.EMAIL_HOST_USER, [conf.EMAIL_HOST_USER, conf.EMAIL_HOST_USER2], msg.as_string())
server.quit()
def worker(topic: str):
"""每个 topic 一个 worker 线程,独立做断流告警"""
table_name = TOPIC_TABLE_MAP[topic]
q = msg_queues[topic]
last_message_time = time.time() # 该 topic 最后一次收到消息时间
last_alert_time = 0 # 该 topic 上一次报警时间
while True:
try:
payload = q.get(timeout=60)
if payload is not None:
try:
save_items(topic, payload)
except Exception as e:
logger.error(f"[{table_name}] save_items 异常: {e}", exc_info=True)
Thread(target=send_error_email, args=(f"[{table_name}] save_items 异常: {e}",)).start()
last_message_time = time.time()
last_alert_time = 0
except queue.Empty:
now = time.time()
if now - last_message_time > 600 and now - last_alert_time > 600:
Thread(
target=send_error_email,
args=(f"time-out 10min [{table_name}] topic={topic} 无数据推送",),
).start()
last_alert_time = now
def normalize_standard(payload):
"""现有 6 个 topic 的格式 [{name,value,time,quality?}] -> [(timex, mpoint_id, value, quality)]"""
records = []
for item in json.loads(payload):
timex = datetime.strptime(item['time'], "%Y%m%d%H%M%S").replace(tzinfo=SHANGHAI)
records.append((timex, item['name'], item.get('value'), item.get('quality')))
return records
# 翁福(KIO)白名单映射:{点位全名 N -> 位号},启动时从 wengfu_mpoint_name 加载。
# 只有命中白名单的点位才把 mpoint_id 缩短为位号;其余(裸位号点、以及前/后位
# 限位这类尾串无区分度、缩短会串台的点)一律原样入库,避免不同物理点碰撞合并。
wengfu_name_map: dict = {}
def load_wengfu_name_map(eng):
"""从 wengfu_mpoint_name 载入 full_name -> mpoint_id 映射。失败不影响主流程。"""
global wengfu_name_map
try:
with eng.connect() as conn:
rows = conn.execute(text(
"SELECT full_name, mpoint_id FROM wengfu_mpoint_name WHERE full_name IS NOT NULL"
)).fetchall()
wengfu_name_map = {full: mid for full, mid in rows}
logger.info(f"载入翁福位号映射 {len(wengfu_name_map)}")
except Exception as e:
logger.error(f"载入 wengfu_mpoint_name 失败,本次将按原样存全名: {e}", exc_info=True)
wengfu_name_map = {}
def code_from_name(name):
"""从翁福(KIO)点位全名切出位号(供建表脚本 load_mpoint_name.py 生成白名单用)。
运行时入库不再直接调用本函数,改走 wengfu_name_map 查表白名单,见 normalize_kio。
全名形如 <中文点位名>_<位号>,中文名本身不含下划线——电流点位除外,
其中文名会嵌入形如 PU6301_1001A 的设备号。位号恒为纯 ASCII故以“最后一个
非 ASCII 字符”(即中文名的结尾)为锚点,锚点之后的 ASCII 尾串即位号。
用非 ASCII 判定而非枚举 CJK 码段,可一并容纳全角符号、罗马数字、生僻字等:
隧道窑液压顶车机B本地和远程_REM_PU5507B -> REM_PU5507B
一号干燥室侧墙温度1_TE_5113 -> TE_5113 (末尾索引数字1归名称)
一号隧道窑布煤机PU6301_1001A电流_PU6301_1001A_I -> PU6301_1001A_I
...前位限位M01_50A_PU_5503_ZAI_ZS5576 -> M01_50A_PU_5503_ZAI_ZS5576
_FLT_PU6302_2009A_22 -> FLT_PU6302_2009A_22 (无中文兜底)
切不出来时原样返回,保证入库不丢数据。
"""
if not name:
return name
pos = 0
for i, ch in enumerate(name):
if ord(ch) > 127: # 任意非 ASCII 字符都属于中文名
pos = i + 1
if pos == 0: # 无中文:整体即位号,去掉前导下划线
return name.lstrip('_') or name
tail = name[pos:]
if tail.startswith('_'):
return tail[1:]
m = re.match(r'^\d+_(.+)$', tail) # 名称末尾的索引数字温度1留给名称
if m:
return m.group(1)
if tail[:1].isalpha(): # 位号紧跟字母M01_50A...
return tail
return name # 兜底:无法切分则原样返回
def normalize_kio(payload):
"""KIO datachange偏移使能格式 -> [(timex, mpoint_id, value, quality)]
结构: {"PNs":{1:V,2:T,3:Q}, "PVs":{基准值}, "Objs":[{N, 1?, 2?, 3?}]}
- 值(1)/质量(3):缺失即等于 PVs 基准值
- 时间(2):为毫秒偏移量,真实时间 = PVs 基准时间 + 偏移(ms);缺失=偏移0
- PVs 时间戳实测为北京时间(Asia/Shanghai),非手册所称 UTC
"""
obj = json.loads(payload)
pvs = obj.get('PVs', {})
base_v = pvs.get('1')
base_q = pvs.get('3')
base_ts = pvs.get('2')
base_time = None
if base_ts:
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
try:
base_time = datetime.strptime(base_ts, fmt).replace(tzinfo=SHANGHAI)
break
except ValueError:
continue
records = []
for o in obj.get('Objs', []):
name = o.get('N')
if not name or base_time is None:
continue
value = o.get('1', base_v)
try:
offset_ms = int(o.get('2', 0))
except (TypeError, ValueError):
offset_ms = 0
timex = base_time + timedelta(milliseconds=offset_ms)
quality = o.get('3', base_q)
# 命中白名单则存位号,否则原样存全名(不丢、不碰撞)
mpoint_id = wengfu_name_map.get(name, name)
records.append((timex, mpoint_id, value, quality))
return records
def route_value(value):
"""按值的类型分列:返回 (val_float, val_str, val_bool, val_int)。
注意 Python bool 是 int 子类,必须先判 bool 再判 int。"""
if isinstance(value, bool):
return None, None, value, None
if isinstance(value, int):
return None, None, None, value
if isinstance(value, float):
return value, None, None, None
# 字符串或其它:能转数字进 val_float否则当文本
try:
return float(value), None, None, None
except (TypeError, ValueError):
return None, (str(value) if value is not None else None), None, None
def save_items(topic, payload):
table_name = TOPIC_TABLE_MAP.get(topic)
if not table_name:
logger.error(f"未知 topic{topic},跳过入库")
return
normalize = normalize_kio if topic in KIO_TOPICS else normalize_standard
records = normalize(payload)
value_cols = TABLE_VALUE_COLS.get(table_name, DEFAULT_VALUE_COLS)
col_list = "timex, mpoint_id, " + ", ".join(value_cols)
param_list = ":timex, :mpoint_id, " + ", ".join(f":{c}" for c in value_cols)
sql_str = f"INSERT INTO {table_name} ({col_list}) VALUES ({param_list})"
table_mpoint_dict = mpoint_dict.setdefault(table_name, {})
save_list = []
for timex, mpoint_id, value, quality in records:
last_timex: datetime = table_mpoint_dict.get(mpoint_id, None)
if timex.minute not in getattr(conf, "SAVE_MINUTES", [2, 7, 12, 17, 22, 27, 32, 37, 42, 47, 52, 57]) or (
last_timex and last_timex.minute == timex.minute):
continue
else:
val_float, val_str, val_bool, val_int = route_value(value)
# 质量戳(Q):取不到则存 NULL
val_q = None
if quality is not None:
try:
val_q = int(quality)
except (TypeError, ValueError):
val_q = None
save_list.append({"timex": timex, "mpoint_id": mpoint_id,
"val_float": val_float, "val_str": val_str, "val_q": val_q,
"val_bool": val_bool, "val_int": val_int})
if save_list:
with engine.connect() as conn:
for item in save_list:
is_ok = False
try:
conn.execute(text(sql_str), item)
is_ok = True
except IntegrityError:
logger.error(f"[{table_name}] {item['mpoint_id']}-主键冲突")
except Exception as e:
logger.error(f"[{table_name}] {item['mpoint_id']}-保存数据失败", exc_info=True)
Thread(target=send_error_email, args=(f"[{table_name}] {item['mpoint_id']}-保存数据失败-{e}",)).start()
if is_ok:
conn.commit()
table_mpoint_dict[item['mpoint_id']] = item['timex']
def get_table_stats():
"""统计 mplogx% 开头各表的数据量,返回可序列化的 dict"""
with engine.connect() as conn:
tables = conn.execute(text("""
SELECT tablename
FROM pg_tables
WHERE schemaname = current_schema()
AND tablename LIKE 'mplogx%'
ORDER BY tablename
""")).fetchall()
if not tables:
return {"tables": [], "summary": {}}
# 表名来自 pg_tables受数据库控制用双引号包裹标识符
parts = []
for (tablename,) in tables:
parts.append(f"""
SELECT
'{tablename}' AS table_name,
COUNT(DISTINCT mpoint_id)::bigint AS point_count_1h,
approximate_row_count('{tablename}')::numeric AS approximate_row_count
FROM "{tablename}"
WHERE timex >= now() - interval '1 hour'
AND timex < now()
""")
union_sql = "\nUNION ALL\n".join(parts)
rows = conn.execute(text(f"""
SELECT table_name, point_count_1h, approximate_row_count
FROM ({union_sql}) AS per_table
ORDER BY table_name
""")).fetchall()
result = []
for table_name, point_count_1h, approximate_row_count in rows:
result.append({
"table_name": table_name,
"point_count_1h": int(point_count_1h) if point_count_1h is not None else 0,
"approximate_row_count": int(approximate_row_count) if approximate_row_count is not None else 0,
})
total_points = sum(r["point_count_1h"] for r in result)
total_rows = sum(r["approximate_row_count"] for r in result)
return {
"tables": result,
"summary": {
"point_count_1h_total": total_points,
"approximate_row_count_total": total_rows,
"approximate_row_count_total_yi": round(total_rows / 100000000, 2),
},
}
class StatsHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path.split('?', 1)[0] != '/stats':
self._write_json(404, {"error": "not found"})
return
try:
self._write_json(200, get_table_stats())
except Exception as e:
logger.error("获取表统计失败", exc_info=True)
self._write_json(500, {"error": str(e)})
def _write_json(self, status, data):
body = json.dumps(data, ensure_ascii=False).encode('utf-8')
self.send_response(status)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
logger.info("HTTP %s - %s", self.address_string(), format % args)
def start_http_server():
host = getattr(conf, 'HTTP_HOST', '0.0.0.0')
port = getattr(conf, 'HTTP_PORT', 5800)
server = ThreadingHTTPServer((host, port), StatsHandler)
logger.info(f"HTTP stats server listening on http://{host}:{port}/stats")
server.serve_forever()
def on_connect(mqttc: mqtt.Client, userdata, flags, rc, properties):
if rc == 0:
logger.info("Connected to MQTT broker")
topics = [(t, 1) for t in TOPIC_TABLE_MAP.keys()]
mqttc.subscribe(topics)
logger.info(f"Subscribed to topics: {[t for t, _ in topics]}")
else:
logger.error("Failed to connect to MQTT broker")
def on_message(mqttc: mqtt.Client, userdata, msg: mqtt.MQTTMessage):
topic = msg.topic
q = msg_queues.get(topic)
if q is not None:
q.put(msg.payload)
def on_disconnect(mqttc: mqtt.Client, userdata, disconnect_flags, reason_code, properties):
logger.error(f"Disconnected from MQTT broker__:{disconnect_flags}, {reason_code}")
def start_mqtt():
client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id='hfnf_105')
client.username_pw_set(conf.MQTT_USERNAME, conf.MQTT_PASSWORD)
client.on_connect = on_connect
client.on_message = on_message
client.on_disconnect = on_disconnect
client.connect(host=conf.MQTT_HOST, port=conf.MQTT_PORT)
client.loop_forever()
if __name__ == '__main__':
try:
engine = create_engine(conf.DATABASE_URL)
logger.info("Connected to database")
load_wengfu_name_map(engine) # 载入翁福位号白名单
for _topic in TOPIC_TABLE_MAP:
Thread(target=worker, args=(_topic,), daemon=True).start()
logger.info(f"Worker thread started for topic={_topic} -> {TOPIC_TABLE_MAP[_topic]}")
Thread(target=start_http_server, daemon=True).start()
start_mqtt()
except Exception:
logger.error("异常退出", exc_info=True)
Thread(target=send_error_email, args=(f'{traceback.format_exc()}')).start()
raise