rs485-lpc/lpc_web.py

450 lines
16 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

# -*- 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()