#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 鹤仆云 · 设备模拟器(演示用) 它扮演的角色是「网关 + 设备」,不是业务层。数据路径和真设备完全一致: sim_device.py ──HTTP──→ M 层 /internal/iot/v1/** ──→ iot_event │ AlarmEngine(业务层) ↓ alarm + notify_task ↓ 家属端 H5 所以模拟器只调三个内部接口,一个业务接口都不碰: I-01 POST /internal/iot/v1/events/batch 事件 I-02 POST /internal/iot/v1/device-status/batch 状态快照(电量/信号/在线) I-12 POST /internal/iot/v1/raw-frames 原文帧(Base64) 用法: python sim_device.py 正常节奏(门磁、水振、电流随机冒出) python sim_device.py --fast 快节奏,演示时用 python sim_device.py --seed 启动先补几条历史,页面一打开就有内容 python sim_device.py --quiet 只开控制台,不自动产生事件 控制台:http://127.0.0.1:9901 —— 按钮按设备类型出现(门磁只有开门/关门,水浸只有漏水/已止), 选错设备发不出对应的告警——真实设备也是这样。 """ import argparse import base64 import hashlib import json import os import random import threading import time import urllib.error import urllib.parse import urllib.request from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, HTTPServer BASE = os.environ.get("SIM_BASE", "http://127.0.0.1:8080") PORT = int(os.environ.get("SIM_PORT", "9901")) CST = timezone(timedelta(hours=8)) OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) # ---------------------------------------------------------------- 设备台账 # sensor_id / source_channel 必须和 01_初始化_设备台账.sql 对得上, # 对不上就会被 M 层判成「未建档」丢进 unregistered_device,家属端看不见。 # # ★ type 必须和库里 device.type_code 一致(2026-11-09 修): # 之前 3F7A9C23 在这里写成「厨房水浸」,库里其实是「床头SOS」, # 结果控制台选门磁点 SOS 也能发出去、还生成告警——纯属模拟器在骗自己。 # 通道名必须是后端 Channel 枚举的四个之一:OWN_GW / HORN_HOST / HORN_CELL / MIR_CELL。 # (曾经写成 H_MQTT,后端直接 400 "sourceChannel 非法",断电/来电永远发不出去) DEVICES = [ dict(sn="3F7A9C21", dev="T-G-01", name="入户门磁", type="DOOR", ch="OWN_GW", hh=9001, loc="入户门"), dict(sn="3F7A9C22", dev="T-G-02", name="卧室门磁", type="DOOR", ch="OWN_GW", hh=9003, loc="卧室"), dict(sn="3F7A9C23", dev="T-G-03", name="床头SOS", type="SOS", ch="OWN_GW", hh=9001, loc="卧室床头"), dict(sn="3F7A9C24", dev="T-G-04", name="厨房水浸", type="WATER", ch="OWN_GW", hh=9003, loc="厨房水槽下"), dict(sn="MIR:861234567890123", dev="T-S-01", name="卫生间水浸", type="WATER", ch="MIR_CELL", hh=9001, loc="卫生间"), dict(sn="HORN:SN12345678", dev="T-H-01", name="客厅烟感", type="SMOKE", ch="HORN_HOST", hh=9001, loc="客厅"), # 水振(麦乐克那台 HORN:999111 也在库里,型号 VIB)。它报的是"用水活动", # 不是告警——放进来是为了能演示"这类事件只进基线、不弹窗"。 dict(sn="HORN:999111", dev="D1794213326404263", name="水振", type="VIB", ch="HORN_HOST", hh=None, loc="厨房水管"), # 验收专用:一台"已入库、还没人绑"的门磁。账号 A 手填 A1B2C3D4 绑上之后, # 在控制台把设备选成它再点「门打开了」,就能看见完整闭环。 dict(sn="A1B2C3D4", dev="T-G-09", name="验收门磁", type="DOOR", ch="OWN_GW", hh=9001, loc="验收"), ] DEV_BY_SN = {d["sn"]: d for d in DEVICES} # 设备类型码(J-00 §1.3 payload.deviceTypeRaw) TYPE_RAW = {"DOOR": 1, "VIB": 2, "CURRENT": 3, "WATER": 4, "SOS": 5, "SMOKE": 6} # 每种设备能干什么。列表里没有的动作,控制台不会显示,后端也会拒。 # 这不是"模拟器偷懒"——真实设备也一样:门磁没有水浸探针,物理上就发不出漏水。 ACTIONS = { "DOOR": [("door", "🚪 门打开了", "warn"), ("door_close", "✅ 门关上了", "ok")], "WATER": [("leak", "💧 漏水了", "hot"), ("leak_clear", "✅ 漏水已止", "ok")], "SOS": [("sos", "🆘 按下 SOS", "hot")], # 烟感:J-00 §1.5.1 是 SMOKE / SMOKE_CLEAR,规则表里 SECOND + URGENT,与漏水同一套 "SMOKE": [("smoke", "🔥 烟感报警", "hot"), ("smoke_clear", "✅ 烟雾已散", "ok")], # 水振:J-00 §1.5.3 里它报的是"用水活动" WATER_ACTIVITY / _STOP,不是告警事件。 # 它喂给基线和「水振+电流」组合研判,本身不弹窗——所以按钮上直接写清楚。 "VIB": [("vib", "💧 用水活动(不告警)", ""), ("vib_stop", "用水停止", "")], } # 任何设备都能做的:断电/来电(网关级事件)、心跳 COMMON_ACTIONS = [("power_off", "⚡ 设备断电", "warn"), ("power_on", "🔌 设备来电", "ok"), ("status", "📊 上报心跳", "")] # 每台设备的运行态。seq 必须单调递增,否则 M 层会判成重传或断档。 STATE = {} LOCK = threading.Lock() COUNTER = {"n": 0} LOG = [] # 最近 60 条动作,控制台轮询显示 LOG_CAP = 60 def now_iso(dt=None): dt = dt or datetime.now(CST) return dt.strftime("%Y-%m-%dT%H:%M:%S+08:00") def log(msg): line = time.strftime("%H:%M:%S ") + msg with LOCK: LOG.append(line) del LOG[:-LOG_CAP] print(line, flush=True) # ---------------------------------------------------------------- HTTP def post(path, body): data = json.dumps(body, ensure_ascii=False).encode("utf-8") req = urllib.request.Request(BASE + path, data=data, method="POST", headers={"Content-Type": "application/json"}) try: with OPENER.open(req, timeout=10) as r: return json.loads(r.read().decode("utf-8") or "{}") except urllib.error.HTTPError as e: body = e.read().decode("utf-8", "ignore")[:200] return {"code": e.code, "msg": body} except Exception as e: # noqa: BLE001 return {"code": -1, "msg": str(e)} def next_seq(dev): STATE[dev]["seq"] += 1 return STATE[dev]["seq"] def raw_ref(): return hashlib.md5(str(random.random()).encode()).hexdigest() # ---------------------------------------------------------------- 发事件 def emit(dev_sn, event_type, payload=None, when=None, channel=None, with_raw=True): """发一条事件。when 可传 datetime,用来补历史和制造断档场景。""" d = DEV_BY_SN[dev_sn] st = STATE[d["dev"]] with LOCK: COUNTER["n"] += 1 n = COUNTER["n"] seq = next_seq(d["dev"]) ref = raw_ref() ev = { "eventId": "EVT-%s-%05d" % (time.strftime("%m%d%H%M%S"), n), "sensorId": dev_sn, "sourceChannel": channel or d["ch"], "eventType": event_type, "deviceTime": now_iso(when), "receivedAt": now_iso(), "tsSource": 3, # 3 = 网关打戳(G 线恒定) "protocolVersion": "gw-1.2", "seq": str(seq), "payload": payload or {}, "rawRef": ref, } r = post("/internal/iot/v1/events/batch", {"sourceChannel": channel or d["ch"], "events": [ev]}) ok = r.get("code") == 0 acc = (r.get("data") or {}).get("accepted", 0) if ok else 0 if ok: if acc: log("✓ %s(%s)→ %s seq=%s" % (d["name"], dev_sn, event_type, seq)) else: reason = json.dumps(r.get("data", {}), ensure_ascii=False)[:300] log("○ %s(%s)→ %s 未被收下:%s" % (d["name"], dev_sn, event_type, reason)) else: log("✗ %s(%s)→ %s 被 M 层拒绝:%s" % (d["name"], dev_sn, event_type, r.get("msg"))) if with_raw: frame = base64.b64encode(b"\xAA\xBB" + bytes([seq % 256]) + dev_sn.encode()[:12]).decode() post("/internal/iot/v1/raw-frames", {"sourceChannel": channel or d["ch"], "frames": [{ "rawRef": ref, "sensorId": dev_sn, "sourceChannel": channel or d["ch"], "receivedAt": now_iso(), "rawFrame": frame, "frameBytes": len(frame), "gatewayDevId": "GW-SIM-01", "protocolVersion": "gw-1.2", }]}) return {"ok": bool(ok and acc), "accepted": acc, "msg": None if ok else r.get("msg")} def emit_status(): """状态快照:电量、信号、在线。走 I-02,覆盖写 device_tech_cache。""" statuses = [] for d in DEVICES: st = STATE[d["dev"]] statuses.append({ "sensorId": d["sn"], "sourceChannel": d["ch"], "online": st["online"], "lastSeenAt": now_iso(), "batteryMv": st["battery"], "csq": st["csq"], "lastSeq": st["seq"], "lostCnt": 0, "protocolVersion": "gw-1.2", "reportedAt": now_iso(), }) post("/internal/iot/v1/device-status/batch", {"sourceChannel": "OWN_GW", "statuses": statuses}) # ---------------------------------------------------------------- 场景 def init_state(): for d in DEVICES: STATE[d["dev"]] = dict( seq=random.randint(100, 400), battery=random.randint(3450, 3900), csq=random.randint(16, 28), online=True, door_open_at=None, leak_at=None, power_off_at=None, ) def tick(fast=False): """一轮随机行为。概率按 5 秒一轮算,故意压低——真设备的动静比演示脚本小得多。""" speed = 3.0 if fast else 1.0 g = DEV_BY_SN["3F7A9C21"] st = STATE["T-G-01"] # 门磁:开 → 过一会儿关 if st["door_open_at"] is None and random.random() < 0.10 * speed: st["door_open_at"] = time.time() st["battery"] -= random.randint(1, 3) emit("3F7A9C21", "DOOR_OPEN", {"deviceTypeRaw": 1, "rawEventCode": 1, "batteryMv": st["battery"]}) elif st["door_open_at"] and time.time() - st["door_open_at"] > random.randint(20, 50): st["door_open_at"] = None emit("3F7A9C21", "DOOR_CLOSE", {"deviceTypeRaw": 1, "rawEventCode": 2, "batteryMv": st["battery"]}) # 水浸:漏了就等它自己解除 w = STATE["T-G-04"] if w["leak_at"] is None and random.random() < 0.02 * speed: w["leak_at"] = time.time() emit("3F7A9C24", "WATER_LEAK", {"deviceTypeRaw": 4, "rawEventCode": 1, "batteryMv": w["battery"]}) elif w["leak_at"] and time.time() - w["leak_at"] > random.randint(40, 100): w["leak_at"] = None emit("3F7A9C24", "WATER_LEAK_CLEAR", {"deviceTypeRaw": 4, "rawEventCode": 2, "batteryMv": w["battery"]}) # S 线水浸:裸 TCP 直连,没有网关打戳,tsSource 恒 2 s = STATE["T-S-01"] if s["leak_at"] is None and random.random() < 0.012 * speed: s["leak_at"] = time.time() emit("MIR:861234567890123", "WATER_LEAK", {"deviceTypeRaw": 4, "rawEventCode": 1, "batteryMv": s["battery"]}, channel="MIR_CELL") elif s["leak_at"] and time.time() - s["leak_at"] > random.randint(40, 90): s["leak_at"] = None emit("MIR:861234567890123", "WATER_LEAK_CLEAR", {"deviceTypeRaw": 4, "rawEventCode": 2, "batteryMv": s["battery"]}, channel="MIR_CELL") # SOS:真设备极少误触,这里给 0.4%。必须由 SOS 那台发,门磁发不出来。 if random.random() < 0.004 * speed: st_sos = STATE["T-G-03"] emit("3F7A9C23", "SOS_PRESS", {"deviceTypeRaw": 5, "rawEventCode": 1, "batteryMv": st_sos["battery"]}) def seed_history(): """补几条历史,让页面一打开就有东西看。时间戳往回拨,走设备时间。""" now = datetime.now(CST) log("补历史:3 条(40 分钟前水浸、25 分钟前门磁、8 分钟前 SOS)") emit("3F7A9C24", "WATER_LEAK", {"deviceTypeRaw": 4, "rawEventCode": 1, "batteryMv": 3610}, when=now - timedelta(minutes=40)) emit("3F7A9C21", "DOOR_OPEN", {"deviceTypeRaw": 1, "rawEventCode": 1, "batteryMv": 3720}, when=now - timedelta(minutes=25)) emit("3F7A9C23", "SOS_PRESS", {"deviceTypeRaw": 5, "rawEventCode": 1, "batteryMv": 3720}, when=now - timedelta(minutes=8)) # ---------------------------------------------------------------- 控制台 PAGE = """
| 设备 | 设备编号 | 类型 | 电量 | 信号 | seq |
|---|