可用的文件,全部
This commit is contained in:
159
mqtt_web/app.py
Normal file
159
mqtt_web/app.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
3D Printer STM32 MQTT Web Control Panel
|
||||
========================================
|
||||
Broker: mqtt.beihong.wang:1883
|
||||
Subscribe: /3D/message/put (STM32 responses)
|
||||
Publish: /3D/message (commands to STM32)
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import queue
|
||||
from flask import Flask, render_template, jsonify, request, Response
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# --- MQTT Config ---
|
||||
MQTT_BROKER = "mqtt.beihong.wang"
|
||||
MQTT_PORT = 1883
|
||||
MQTT_USER = "3D_Stm32"
|
||||
MQTT_PASS = "123456"
|
||||
MQTT_CLIENT_ID = "3D_WebPanel"
|
||||
MQTT_TOPIC_PUB = "/3D/message"
|
||||
MQTT_TOPIC_SUB = "/3D/message/put"
|
||||
|
||||
# --- Message queue for SSE ---
|
||||
msg_queue = queue.Queue()
|
||||
|
||||
# --- MQTT connection state ---
|
||||
mqtt_state = {"connected": False}
|
||||
|
||||
|
||||
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||
if reason_code == 0:
|
||||
mqtt_state["connected"] = True
|
||||
client.subscribe(MQTT_TOPIC_SUB, qos=1)
|
||||
print(f"[MQTT] Connected, subscribed to {MQTT_TOPIC_SUB}")
|
||||
else:
|
||||
mqtt_state["connected"] = False
|
||||
print(f"[MQTT] Connect failed, code={reason_code}")
|
||||
|
||||
|
||||
def on_disconnect(client, userdata, reason_code, properties=None):
|
||||
mqtt_state["connected"] = False
|
||||
print(f"[MQTT] Disconnected, code={reason_code}")
|
||||
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
try:
|
||||
payload = msg.payload.decode("utf-8", errors="replace")
|
||||
print(f"[MQTT] << {msg.topic}: {payload}")
|
||||
msg_queue.put({"topic": msg.topic, "payload": payload, "time": time.strftime("%H:%M:%S")})
|
||||
except Exception as e:
|
||||
print(f"[MQTT] Message decode error: {e}")
|
||||
|
||||
|
||||
def mqtt_loop():
|
||||
client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=MQTT_CLIENT_ID)
|
||||
client.username_pw_set(MQTT_USER, MQTT_PASS)
|
||||
client.on_connect = on_connect
|
||||
client.on_disconnect = on_disconnect
|
||||
client.on_message = on_message
|
||||
|
||||
while True:
|
||||
try:
|
||||
print(f"[MQTT] Connecting to {MQTT_BROKER}:{MQTT_PORT} ...")
|
||||
client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
|
||||
client.loop_forever()
|
||||
except Exception as e:
|
||||
print(f"[MQTT] Error: {e}, retrying in 5s ...")
|
||||
mqtt_state["connected"] = False
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
# --- Flask routes ---
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/status")
|
||||
def api_status():
|
||||
return jsonify({"connected": mqtt_state["connected"]})
|
||||
|
||||
|
||||
@app.route("/api/send", methods=["POST"])
|
||||
def api_send():
|
||||
data = request.get_json(force=True, silent=True)
|
||||
if not data:
|
||||
return jsonify({"ok": False, "msg": "invalid json"}), 400
|
||||
|
||||
if not mqtt_state["connected"]:
|
||||
return jsonify({"ok": False, "msg": "mqtt disconnected"}), 503
|
||||
|
||||
try:
|
||||
payload = json.dumps(data, separators=(',', ':'), ensure_ascii=False)
|
||||
# Publish to MQTT broker
|
||||
mqtt_client.publish(MQTT_TOPIC_PUB, payload, qos=1)
|
||||
print(f"[MQTT] >> {MQTT_TOPIC_PUB}: {payload}")
|
||||
return jsonify({"ok": True})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/stream")
|
||||
def api_stream():
|
||||
"""Server-Sent Events endpoint for real-time messages"""
|
||||
def generate():
|
||||
while True:
|
||||
try:
|
||||
msg = msg_queue.get(timeout=25)
|
||||
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
|
||||
except queue.Empty:
|
||||
yield ": keepalive\n\n"
|
||||
|
||||
resp = Response(generate(), mimetype="text/event-stream")
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
resp.headers["X-Accel-Buffering"] = "no"
|
||||
return resp
|
||||
|
||||
|
||||
@app.route("/api/poll")
|
||||
def api_poll():
|
||||
"""轮询获取新消息"""
|
||||
try:
|
||||
msg = msg_queue.get_nowait()
|
||||
return jsonify(msg)
|
||||
except queue.Empty:
|
||||
return jsonify({"msg": None})
|
||||
|
||||
|
||||
# --- Global MQTT client reference ---
|
||||
mqtt_client = None
|
||||
|
||||
|
||||
@app.before_request
|
||||
def ensure_mqtt_client():
|
||||
global mqtt_client
|
||||
if mqtt_client is None:
|
||||
# Create a client just for publishing (the loop thread has its own)
|
||||
mqtt_client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=MQTT_CLIENT_ID + "_pub")
|
||||
mqtt_client.username_pw_set(MQTT_USER, MQTT_PASS)
|
||||
try:
|
||||
mqtt_client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
|
||||
mqtt_client.loop_start()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Start MQTT background thread
|
||||
t = threading.Thread(target=mqtt_loop, daemon=True)
|
||||
t.start()
|
||||
# Start Flask web server
|
||||
app.run(host="127.0.0.1", port=3000, debug=False)
|
||||
2
mqtt_web/requirements.txt
Normal file
2
mqtt_web/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask>=3.0
|
||||
paho-mqtt>=2.0
|
||||
158
mqtt_web/templates/index.html
Normal file
158
mqtt_web/templates/index.html
Normal file
@@ -0,0 +1,158 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>3D打印控制面板</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{--bg:#0f1923;--card:#1a2736;--border:#2a3a4a;--text:#e0e6ed;--dim:#7a8a9a;--green:#00e676;--red:#ff5252;--blue:#448aff;--orange:#ff9100;--r:8px}
|
||||
body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);font-size:13px;padding:12px}
|
||||
.header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
|
||||
h1{font-size:1.15rem;background:linear-gradient(135deg,var(--blue),var(--green));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.status-bar{display:flex;align-items:center;gap:6px;font-size:.8rem;color:var(--dim)}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--red);transition:.3s}
|
||||
.dot.on{background:var(--green);box-shadow:0 0 6px var(--green)}
|
||||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:640px;margin:0 auto}
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:10px 12px}
|
||||
.card-title{font-size:.75rem;color:var(--dim);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}
|
||||
.btn-row{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.btn{padding:6px 14px;border:none;border-radius:6px;font-size:.82rem;font-weight:600;cursor:pointer;transition:all .15s;outline:none;line-height:1.4}
|
||||
.btn:active{transform:scale(.96)}
|
||||
.btn-blue{background:var(--blue);color:#fff}.btn-blue:hover{background:#5c9aff}
|
||||
.btn-green{background:var(--green);color:#0f1923}.btn-green:hover{background:#33eb91}
|
||||
.btn-red{background:var(--red);color:#fff}.btn-red:hover{background:#ff6b6b}
|
||||
.btn-orange{background:var(--orange);color:#fff}.btn-orange:hover{background:#ffa733}
|
||||
.row{display:flex;align-items:center;gap:8px;margin-top:6px}
|
||||
.row label{color:var(--dim);font-size:.8rem;min-width:60px}
|
||||
.row input[type=range]{flex:1;accent-color:var(--blue);height:4px}
|
||||
.row .val{min-width:28px;text-align:center;font-weight:600;color:var(--blue)}
|
||||
.full{grid-column:1/-1}
|
||||
.json-box{width:100%;height:50px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 8px;font-family:Consolas,Monaco,monospace;font-size:.8rem;resize:vertical;outline:none}
|
||||
.json-box:focus{border-color:var(--blue)}
|
||||
.log-area{max-height:180px;overflow-y:auto;padding-right:2px}
|
||||
.log-area::-webkit-scrollbar{width:4px}
|
||||
.log-area::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
|
||||
.log-item{padding:4px 6px;margin-bottom:3px;border-radius:4px;background:var(--bg);font-size:.78rem;word-break:break-all;line-height:1.4}
|
||||
.log-item .lt{color:var(--dim);font-size:.7rem}
|
||||
.log-item .lp{color:var(--text)}
|
||||
.log-item.error .lp{color:var(--red)}
|
||||
.clear-btn{margin-top:6px;padding:3px 10px;border:1px solid var(--border);background:0;color:var(--dim);border-radius:4px;font-size:.75rem;cursor:pointer}
|
||||
.clear-btn:hover{border-color:var(--red);color:var(--red)}
|
||||
footer{text-align:center;color:var(--dim);font-size:.7rem;margin-top:10px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>3D 打印控制面板</h1>
|
||||
<div class="status-bar"><div class="dot" id="dot"></div><span id="connText">连接中...</span></div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">查询</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-blue" onclick="send({cmd:'status'})">查询状态</button>
|
||||
<button class="btn btn-red" onclick="if(confirm('确认恢复默认?'))send({cmd:'reset'})">恢复默认</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">灯光</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-green" onclick="send({cmd:'light',value:'on'})">开灯</button>
|
||||
<button class="btn btn-red" onclick="send({cmd:'light',value:'off'})">关灯</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">风扇开关</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-green" onclick="send({cmd:'fan',value:'on'})">开启</button>
|
||||
<button class="btn btn-red" onclick="send({cmd:'fan',value:'off'})">关闭</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>模式</label>
|
||||
<button class="btn btn-blue" style="padding:4px 10px;font-size:.78rem" onclick="send({cmd:'fan_mode',value:'auto'})">自动</button>
|
||||
<button class="btn btn-orange" style="padding:4px 10px;font-size:.78rem" onclick="send({cmd:'fan_mode',value:'manual'})">手动</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title">风扇阈值</div>
|
||||
<div class="row">
|
||||
<label>温度 °C</label>
|
||||
<input type="range" id="threshold" min="15" max="80" step="1" value="30" oninput="document.getElementById('thVal').textContent=this.value">
|
||||
<span class="val" id="thVal">30</span>
|
||||
<button class="btn btn-blue" style="padding:4px 10px;font-size:.78rem" onclick="send({cmd:'fan_threshold',value:+document.getElementById('threshold').value})">设置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card full">
|
||||
<div class="card-title">自定义指令</div>
|
||||
<textarea class="json-box" id="customJson" placeholder='{"cmd":"status"}'></textarea>
|
||||
<div class="btn-row" style="margin-top:6px">
|
||||
<button class="btn btn-blue" style="padding:4px 14px;font-size:.78rem" onclick="sendCustom()">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card full">
|
||||
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span>消息日志</span>
|
||||
<button class="clear-btn" onclick="clearLog()">清空</button>
|
||||
</div>
|
||||
<div class="log-area" id="logArea">
|
||||
<div style="color:var(--dim);text-align:center;padding:16px;font-size:.78rem">等待消息...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer>mqtt.beihong.wang · /3D/message → STM32 · /3D/message/put ← STM32</footer>
|
||||
|
||||
<script>
|
||||
const API='';let firstMsg=true;
|
||||
function send(o){fetch(API+'/api/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(o)}).then(r=>r.json()).then(d=>{if(!d.ok)addLog('SYS',d.msg||'失败',true)})}
|
||||
function sendCustom(){const t=document.getElementById('customJson').value.trim();if(!t)return;try{send(JSON.parse(t))}catch(e){addLog('SYS','JSON错误: '+e.message,true)}}
|
||||
|
||||
function fmtPayload(payload){
|
||||
try{
|
||||
const j=JSON.parse(payload);
|
||||
if(j.status==='error') return `<span style="color:var(--red)">✗ ${j.msg||'错误'}</span>`;
|
||||
if(j.data){
|
||||
const d=j.data;
|
||||
const mode=d.fan_mode==='auto'?'自动':'手动';
|
||||
const fan=d.fan==='on'?'<span style="color:var(--green)">开启</span>':'<span style="color:var(--dim)">关闭</span>';
|
||||
const light=d.light==='on'?'<span style="color:var(--green)">开启</span>':'<span style="color:var(--dim)">关闭</span>';
|
||||
return `风扇: ${fan} | 模式: ${mode} | 阈值: ${d.fan_threshold}°C | 灯光: ${light}`;
|
||||
}
|
||||
if(j.msg) return `<span style="color:var(--green)">✓ ${j.msg}</span>`;
|
||||
}catch(e){}
|
||||
return esc(payload);
|
||||
}
|
||||
|
||||
function addLog(topic,payload,isErr){
|
||||
const a=document.getElementById('logArea');if(firstMsg){a.innerHTML='';firstMsg=false}
|
||||
const d=document.createElement('div');d.className='log-item'+(isErr?' error':'');
|
||||
d.innerHTML=`<span class="lt">${new Date().toLocaleTimeString()}</span> <span class="lp">${isErr?'<span style="color:var(--red)">✗ '+esc(payload)+'</span>':fmtPayload(payload)}</span>`;
|
||||
a.prepend(d);while(a.children.length>80)a.removeChild(a.lastChild)
|
||||
}
|
||||
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
function clearLog(){document.getElementById('logArea').innerHTML='<div style="color:var(--dim);text-align:center;padding:16px;font-size:.78rem">等待消息...</div>';firstMsg=true}
|
||||
|
||||
/* 轮询消息 + 状态 */
|
||||
function poll(){
|
||||
fetch(API+'/api/status').then(r=>r.json()).then(d=>{
|
||||
document.getElementById('dot').className='dot'+(d.connected?' on':'');
|
||||
document.getElementById('connText').textContent=d.connected?'MQTT 已连接':'MQTT 已断开';
|
||||
});
|
||||
fetch(API+'/api/poll').then(r=>r.json()).then(d=>{
|
||||
if(d.payload){addLog(d.topic||'MQTT',d.payload,false);console.log('poll got:',d.payload)}
|
||||
}).catch(()=>{});
|
||||
}
|
||||
setInterval(poll,1500);poll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user