rs485-lpc/lpc_web.py

450 lines
16 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
# LPC 0140650 web UI,运行于 Ubuntu 192.168.58.7:8090
# LPC 自治架构:Modbus 轮询+闭环在 LPC Lua(触发器 #14/#15),本程序只做监控/记录/HTTP 转发
import json
import re
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
LPC = "http://192.168.58.109"
PORT = 8090
HIST_FILE = "/home/tonycao/rs485-lpc/history.jsonl"
SAMPLE_INTERVAL = 30 # 秒
HIST_KEEP = 48 * 3600 # 48 小时
TRIG = {"BRT": 2, "LUX": 8, "LXT": 9, "LX2": 10, "FX": 11, "SC": 12,
"FAD": 18, "PER": 19, "DBD": 20}
TRIG_LINK_ON = 16
TRIG_LINK_OFF = 17
_hist = [] # [[t_ms, brt, out, lux], ...] lux 可为 None
_hist_lock = threading.Lock()
_samples_since_compact = 0
def lpc_json(path):
with urllib.request.urlopen(LPC + path, timeout=5) as r:
return json.loads(r.read().decode("utf-8", "replace"))
def lpc_lua(names):
d = lpc_json("/api/lua?variables=" + ",".join(names))
return d
def lua_num(v):
try:
return int(round(float(v)))
except Exception:
return None
def read_lux():
try:
return lua_num(lpc_lua(["lux_value"]).get("lux_value"))
except Exception:
return None
def link_on():
try:
return bool(lpc_lua(["lux_link_on"]).get("lux_link_on"))
except Exception:
return False
def fire_trigger(num, var=None):
url = LPC + "/api/trigger?num=%d" % num
if var is not None:
url += "&var=%d" % var
req = urllib.request.Request(url, data=b"", method="POST")
with urllib.request.urlopen(req, timeout=5) as r:
r.read()
def sample_once():
t = int(time.time() * 1000)
brt = None
out = None
try:
g = lpc_json("/api/group")
brt = g["groups"][0]["level"]
except Exception:
pass
try:
o = lpc_json("/api/output?universe=dmx:1")
vals = []
for c in o["channels"][:32]:
m = re.search(r"\d+", str(c))
vals.append(int(m.group(0)) if m else 0)
out = round(sum(vals) * 100.0 / (255 * len(vals)), 1)
except Exception:
pass
lux = read_lux()
row = [t, brt, out, lux]
with _hist_lock:
_hist.append(row)
cutoff = t - HIST_KEEP * 1000
while _hist and _hist[0][0] < cutoff:
_hist.pop(0)
try:
with open(HIST_FILE, "a") as f:
f.write(json.dumps(row, separators=(",", ":")) + "\n")
except Exception:
pass
global _samples_since_compact
_samples_since_compact += 1
if _samples_since_compact >= 120: # 每小时压实一次文件
_samples_since_compact = 0
compact_history()
def compact_history():
with _hist_lock:
data = list(_hist)
try:
with open(HIST_FILE, "w") as f:
for r in data:
f.write(json.dumps(r, separators=(",", ":")) + "\n")
except Exception:
pass
def load_history():
try:
cutoff = int(time.time() * 1000) - HIST_KEEP * 1000
with open(HIST_FILE) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
except Exception:
continue
if isinstance(r, list) and len(r) == 4 and r[0] >= cutoff:
_hist.append(r)
except Exception:
pass
def sampler_loop():
while True:
try:
sample_once()
except Exception:
pass
time.sleep(SAMPLE_INTERVAL)
PAGE = r"""<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LPC 灯光控制</title>
<style>
body{font-family:system-ui,"Microsoft YaHei",sans-serif;background:#111;color:#eee;margin:0;padding:12px;max-width:860px;margin:auto}
h1{font-size:20px} h2{font-size:15px;color:#9cf;margin:14px 0 6px}
.card{background:#1d1d1d;border-radius:10px;padding:12px;margin-bottom:12px}
button{font-size:15px;padding:8px 14px;margin:4px;border:0;border-radius:8px;background:#2d6cdf;color:#fff;cursor:pointer}
button.sc{background:#555}
.row{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:4px 0}
.bars{display:grid;grid-template-columns:repeat(16,1fr);gap:4px}
.ch{background:#333;border-radius:4px;text-align:center;font-size:10px;padding:2px 0}
.ch .v{height:38px;display:flex;align-items:flex-end;justify-content:center}
.ch .f{width:80%;border-radius:3px 3px 0 0;background:#fc3}
.tl{display:flex;gap:8px;flex-wrap:wrap}
.t{padding:6px 10px;border-radius:8px;background:#333;font-size:13px}
.t.run{background:#1e5e1e}.t.rel{background:#5e1e1e}
input[type=range]{width:220px}
.sw{font-size:15px;padding:8px 14px;border-radius:8px;border:0;cursor:pointer;background:#555;color:#fff}
.sw.on{background:#1e7e1e}
#lux{font-size:24px;color:#fc3}
.dim{color:#888;font-size:12px}
canvas{width:100%;height:240px;background:#161616;border-radius:8px}
.lg{display:inline-block;width:18px;height:3px;margin:0 5px 2px 12px;vertical-align:middle}
</style>
</head>
<body>
<h1>LPC 0140650 实时状态</h1>
<div class="card">
<h2>照度联动</h2>
<div class="row">
<button id="luxsw" class="sw" onclick="luxlink()">…</button>
<span>照度:<b id="lux">-</b> Lux</span>
</div>
<div class="row">目标照度:
<input type="number" id="lxt" min="1" max="200000" step="10" style="width:90px"> Lux
<button onclick="setlxt()">设定</button>
</div>
<div class="row">轮询周期:
<input type="number" id="per" min="1" max="60" step="1" style="width:60px"> s
<button onclick="setper()">设定</button>
渐变时间:
<input type="number" id="fad" min="0.1" max="10" step="0.1" style="width:60px"> s
<button onclick="setfad()">设定</button>
死区:
<input type="number" id="dbd" min="1" max="1000" step="1" style="width:60px"> Lux
<button onclick="setdbd()">设定</button>
</div>
<div class="dim">闭环在 LPC 内运行(触发器 #14 每秒轮询传感器,#15 Lua 自动调光);轮询周期=闭环调节间隔,照度采集与显示仍每秒刷新;FAD 单位为 0.1s</div>
</div>
<div class="card">
<h2>控制</h2>
<div class="row">效果:
<button onclick="send('FX=01')">常亮</button>
<button onclick="send('FX=02')">呼吸</button>
<button onclick="send('FX=03')">闪烁</button>
<button onclick="send('FX=04')">缓变</button>
</div>
<div class="row">场景:
<button class="sc" onclick="send('SC=01')">全亮</button>
<button class="sc" onclick="send('SC=02')">半亮</button>
<button class="sc" onclick="send('SC=03')">微亮</button>
<button class="sc" onclick="send('SC=04')">全灭</button>
</div>
<div class="row">总亮度:
<input type="range" id="brt" min="0" max="100" value="100" onchange="brt()">
<span id="brtv">100</span>%
</div>
</div>
<div class="card">
<h2>历史曲线(48 小时,30s 采样)</h2>
<canvas id="hist"></canvas>
<div class="dim">
<span class="lg" style="background:#fc3"></span>输出%(左轴 0-100)
<span class="lg" style="background:#f80"></span>总调光 BRT%(左轴)
<span class="lg" style="background:#3cf"></span>照度 Lux(右轴,对数 1-200k)
<span id="histinfo"></span>
</div>
</div>
<div class="card">
<h2>Timeline</h2>
<div class="tl" id="tls"></div>
</div>
<div class="card">
<h2>DMX 输出(通道 1~32)</h2>
<div class="bars" id="bars"></div>
</div>
<div class="dim" id="ts"></div>
<script>
const $=id=>document.getElementById(id);
let luxOn=false, sending=false;
async function j(u){const r=await fetch(u);return r.json();}
async function refresh(){
try{
const tl=await j('/api/timeline');
$('tls').innerHTML=tl.timelines.map(t=>
`<div class="t ${t.state==='running'?'run':(t.state==='released'?'rel':'')}">T${t.num} ${t.name}<br>${t.state} ${(t.position/1000).toFixed(1)}s</div>`).join('');
const out=await j('/api/output?universe=dmx:1');
$('bars').innerHTML=out.channels.slice(0,32).map((c,i)=>{
const v=parseInt(String(c).replace(/\D/g,''))||0;
return `<div class="ch"><div class="v"><div class="f" style="height:${v/255*100}%"></div></div>${i+1}<br>${v}</div>`;}).join('');
const g=await j('/api/group');
const lv=g.groups[0].level;
if(document.activeElement!==$('brt')){$('brt').value=lv;$('brtv').textContent=lv;}
$('ts').textContent='更新于 '+new Date().toLocaleTimeString();
}catch(e){$('ts').textContent='连接失败: '+e;}
}
async function luxstat(){
try{
const s=await j('/luxlink');
luxOn=s.on;
$('luxsw').textContent=s.on?'联动中(点击关闭)':'已关闭(点击开启)';
$('luxsw').className='sw'+(s.on?' on':'');
if(s.lux!=null)$('lux').textContent=s.lux;
if(s.target!=null&&document.activeElement!==$('lxt'))$('lxt').value=s.target;
if(s.period!=null&&document.activeElement!==$('per'))$('per').value=s.period;
if(s.fade!=null&&document.activeElement!==$('fad'))$('fad').value=(Math.round(s.fade*10)/10);
if(s.deadband!=null&&document.activeElement!==$('dbd'))$('dbd').value=s.deadband;
}catch(e){}
}
async function setlxt(){const v=parseInt($('lxt').value);if(v>=1&&v<=200000)send('LXT='+v);}
async function setper(){const v=parseInt($('per').value);if(v>=1&&v<=60)send('PER='+v);}
async function setfad(){const v=Math.round(parseFloat($('fad').value)*10);if(v>=1&&v<=100)send('FAD='+v);}
async function setdbd(){const v=parseInt($('dbd').value);if(v>=1&&v<=1000)send('DBD='+v);}
async function luxlink(){
$('luxsw').textContent='切换中…';
await fetch('/luxlink?on='+(luxOn?'0':'1'),{method:'POST'});
setTimeout(luxstat,1500);
}
async function send(c){
if(sending)return; sending=true;
await fetch('/send?cmd='+encodeURIComponent(c),{method:'POST'});
sending=false; setTimeout(refresh,800); setTimeout(hist,5000);
}
function brt(){
const v=$('brt').value; $('brtv').textContent=v;
send('BRT='+String(v).padStart(3,'0'));
}
const LUXMIN=1, LUXMAX=200000;
function luxY(lux,h,pad){ // 对数轴
const v=Math.max(LUXMIN,Math.min(LUXMAX,lux));
const f=(Math.log10(v)-Math.log10(LUXMIN))/(Math.log10(LUXMAX)-Math.log10(LUXMIN));
return h-pad-f*(h-2*pad);
}
function pctY(p,h,pad){ return h-pad-(p/100)*(h-2*pad); }
async function hist(){
try{
const d=await j('/history');
const pts=d.points;
const cv=$('hist');
const w=cv.clientWidth, h=cv.clientHeight;
const dpr=window.devicePixelRatio||1;
cv.width=w*dpr; cv.height=h*dpr;
const ctx=cv.getContext('2d'); ctx.scale(dpr,dpr);
ctx.clearRect(0,0,w,h);
const pad=28, now=Date.now(), t0=now-48*3600*1000;
const X=t=>pad+(t-t0)/(now-t0)*(w-2*pad);
// 网格 + 时间刻度(每 6h)
ctx.strokeStyle='#333'; ctx.fillStyle='#888'; ctx.font='10px sans-serif'; ctx.lineWidth=1;
for(let k=0;k<=8;k++){
const t=t0+k*6*3600*1000, x=X(t);
ctx.beginPath(); ctx.moveTo(x,pad); ctx.lineTo(x,h-pad); ctx.stroke();
const dt=new Date(t);
ctx.fillText((dt.getMonth()+1)+'/'+dt.getDate()+' '+String(dt.getHours()).padStart(2,'0')+'时', x-24, h-8);
}
// 左轴 0/50/100%
ctx.fillStyle='#fc3';
[0,50,100].forEach(p=>{
const y=pctY(p,h,pad);
ctx.strokeStyle='#2a2a2a'; ctx.beginPath(); ctx.moveTo(pad,y); ctx.lineTo(w-pad,y); ctx.stroke();
ctx.fillText(p+'%', 2, y+3);
});
// 右轴 lux 对数刻度
ctx.fillStyle='#3cf';
[1,10,100,1000,10000,100000,200000].forEach(l=>{
const y=luxY(l,h,pad);
ctx.fillText(l>=1000?(l/1000)+'k':l, w-pad+3, y+3);
});
if(!pts.length){ $('histinfo').textContent=' (暂无数据,采样中…)'; return; }
// 输出% 实线
ctx.strokeStyle='#fc3'; ctx.lineWidth=1.5; ctx.beginPath();
let started=false;
for(const p of pts){
if(p[2]==null){started=false;continue;}
const x=X(p[0]), y=pctY(p[2],h,pad);
started?ctx.lineTo(x,y):ctx.moveTo(x,y); started=true;
}
ctx.stroke();
// BRT% 虚线
ctx.strokeStyle='#f80'; ctx.setLineDash([4,3]); ctx.beginPath(); started=false;
for(const p of pts){
if(p[1]==null){started=false;continue;}
const x=X(p[0]), y=pctY(p[1],h,pad);
started?ctx.lineTo(x,y):ctx.moveTo(x,y); started=true;
}
ctx.stroke(); ctx.setLineDash([]);
// lux 对数
ctx.strokeStyle='#3cf'; ctx.lineWidth=1.5; ctx.beginPath(); started=false;
for(const p of pts){
if(p[3]==null){started=false;continue;}
const x=X(p[0]), y=luxY(p[3],h,pad);
started?ctx.lineTo(x,y):ctx.moveTo(x,y); started=true;
}
ctx.stroke();
const first=new Date(pts[0][0]);
$('histinfo').textContent=` ${pts.length} 点,自 ${first.toLocaleString()}`;
}catch(e){}
}
refresh(); luxstat(); hist();
setInterval(refresh,1500); setInterval(luxstat,6000); setInterval(hist,60000);
window.addEventListener('resize', hist);
</script>
</body>
</html>
"""
class H(BaseHTTPRequestHandler):
def _send(self, code, body, ctype="application/json; charset=utf-8"):
b = body if isinstance(body, bytes) else body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(b)))
self.end_headers()
self.wfile.write(b)
def log_message(self, *a):
pass
def _reqlog(self):
if self.command != "POST":
return
try:
with open("/home/tonycao/rs485-lpc/lpc_web.req.log", "a") as f:
f.write("%s %s %s %s\n" % (time.strftime("%F %T"),
self.client_address[0], self.command, self.path))
except Exception:
pass
def do_GET(self):
u = urlparse(self.path)
if u.path in ("/", "/index.html"):
self._send(200, PAGE, "text/html; charset=utf-8")
elif u.path.startswith("/api/"):
try:
with urllib.request.urlopen(LPC + u.path + (("?" + u.query) if u.query else ""), timeout=5) as r:
self._send(200, r.read(), "application/json")
except Exception as e:
self._send(502, json.dumps({"error": str(e)}))
elif u.path == "/luxlink":
cfg = {}
try:
d = lpc_lua(["lux_target", "lux_fade", "lux_poll_s", "lux_deadband"])
cfg = {"target": lua_num(d.get("lux_target")),
"fade": d.get("lux_fade"),
"period": lua_num(d.get("lux_poll_s")),
"deadband": lua_num(d.get("lux_deadband"))}
except Exception:
pass
self._send(200, json.dumps({"on": link_on(), "lux": read_lux(), **cfg}))
elif u.path == "/history":
with _hist_lock:
data = list(_hist)
self._send(200, json.dumps({"points": data}, separators=(",", ":")))
else:
self._send(404, "{}")
def do_POST(self):
self._reqlog()
u = urlparse(self.path)
q = parse_qs(u.query)
if u.path == "/send":
cmd = q.get("cmd", [""])[0]
m = re.fullmatch(r"(BRT|FX|SC|LUX|LXT|LX2|FAD|PER|DBD)=(\d+)", cmd)
if not m:
self._send(400, json.dumps({"error": "bad cmd"}))
return
try:
fire_trigger(TRIG[m.group(1)], int(m.group(2)))
self._send(200, json.dumps({"ok": True}))
except Exception as e:
self._send(502, json.dumps({"error": str(e)}))
elif u.path == "/luxlink":
try:
fire_trigger(TRIG_LINK_ON if q.get("on", ["0"])[0] == "1" else TRIG_LINK_OFF)
self._send(200, json.dumps({"ok": True}))
except Exception as e:
self._send(502, json.dumps({"error": str(e)}))
else:
self._send(404, "{}")
if __name__ == "__main__":
load_history()
threading.Thread(target=sampler_loop, daemon=True).start()
print("LPC web UI: http://0.0.0.0:%d (history %d pts)" % (PORT, len(_hist)))
ThreadingHTTPServer(("0.0.0.0", PORT), H).serve_forever()