450 lines
16 KiB
Python
450 lines
16 KiB
Python
# -*- 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()
|