文章以1pancel为例
1.docker搭建esphome
点击展开代码
services:
esphome:
container_name: esphome
image: ghcr.io/esphome/esphome
volumes:
- /opt/1panel/apps/local/esphome/esphome/config:/config
- /etc/localtime:/etc/localtime:ro
restart: always
privileged: true
network_mode: host
environment:
- USERNAME=admin
- PASSWORD=feichangchang
- TZ=Asia/Shanghai # ✨ 新增这行,强制指定容器时区为北京时间 2.root下创建esphome_panel.py和esphome_index.html
2.1 编辑esphome_panel.py
代码超长点击展开代码
import os
import re
import time
import subprocess
from datetime import datetime
from flask import Flask, render_template, request, jsonify, Response
# 🎯 自动兼容:让 Flask 优先在当前脚本所在目录、当前目录的 templates 文件夹或 /root 下寻找网页
app = Flask(__name__, template_folder='.')
# 🎯 你的真实 1Panel 挂载宿主机路径
CONFIG_DIR = "/opt/1panel/apps/local/esphome/esphome/config"
LOG_FILE = os.path.join(CONFIG_DIR, "esphome_compile.log")
# 全局编译状态锁
compile_status = {"status": "idle", "message": "Waiting..."}
# 缓存上一次的 CPU 时间和网络数据,用于精准差值计算
sys_cache = {
"last_cpu_time": None,
"last_net_time": time.time(),
"last_rx": 0,
"last_tx": 0
}
def get_esphome_device_name(yaml_path):
try:
with open(yaml_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
match = re.search(r'name:\s*"?([a-zA-Z0-9_-]+)"?', content)
if match: return match.group(1)
except: pass
return None
def get_esphome_docker_version():
try:
res = subprocess.run("docker exec esphome esphome version", shell=True, capture_output=True, text=True)
match = re.search(r'Version:\s*([0-9.]+)', res.stdout)
if match: return match.group(1)
except: pass
return "Unknown"
def load_links_file():
links_file = os.path.join(CONFIG_DIR, "device_links.txt")
links = {}
if os.path.exists(links_file):
try:
with open(links_file, 'r', encoding='utf-8') as f:
for line in f:
if ',' in line:
k, v = line.strip().split(',', 1)
links[k] = v
except: pass
return links
def save_links_file(links):
links_file = os.path.join(CONFIG_DIR, "device_links.txt")
try:
with open(links_file, 'w', encoding='utf-8') as f:
for k, v in links.items(): f.write(f"{k},{v}\n")
except: pass
def is_valid_esphome_yaml(filename):
if filename.startswith('.') or filename in ['secrets.yaml', 'secrets.yml']: return False
return filename.endswith('.yaml') or filename.endswith('.yml')
def get_sys_stats():
global sys_cache
cpu_usage = 0.0
mem_usage = 0.0
rx_speed = 0.0
tx_speed = 0.0
# 1. 采用与 1Panel 同步的底层 CPU 差值算法
try:
with open('/proc/stat', 'r') as f:
fields = [float(column) for column in f.readline().strip().split()[1:5]]
current_idle = fields[3]
current_total = sum(fields)
if sys_cache["last_cpu_time"] is not None:
last_idle, last_total = sys_cache["last_cpu_time"]
idle_delta = current_idle - last_idle
total_delta = current_total - last_total
if total_delta > 0:
cpu_usage = round((1.0 - idle_delta / total_delta) * 100, 1)
if cpu_usage < 0.0: cpu_usage = 0.0
if cpu_usage > 100.0: cpu_usage = 100.0
sys_cache["last_cpu_time"] = (current_idle, current_total)
except: pass
# 2. 精准内存计算 (free -m)
try:
res = subprocess.run("free -m", shell=True, capture_output=True, text=True)
for line in res.stdout.splitlines():
if line.startswith("Mem:"):
parts = line.split()
total = float(parts[1])
used = float(parts[2])
if total > 0: mem_usage = round((used / total) * 100, 1)
break
except: pass
# 3. 网络流量带宽计算
try:
total_rx = 0
total_tx = 0
with open('/proc/net/dev', 'r') as f:
lines = f.readlines()
for line in lines[2:]:
parts = line.split()
if len(parts) >= 9 and parts[0] != 'lo:':
total_rx += int(parts[1])
total_tx += int(parts[9])
now = time.time()
time_diff = now - sys_cache["last_net_time"]
if sys_cache["last_rx"] > 0 and time_diff > 0:
rx_speed = (total_rx - sys_cache["last_rx"]) / time_diff / 1024 / 1024
tx_speed = (total_tx - sys_cache["last_tx"]) / time_diff / 1024 / 1024
sys_cache["last_net_time"] = now
sys_cache["last_rx"] = total_rx
sys_cache["last_tx"] = total_tx
except: pass
return {"cpu": cpu_usage, "mem": mem_usage, "rx": round(rx_speed, 2), "tx": round(tx_speed, 2)}
@app.route('/sys-stats')
def sys_stats_api():
return jsonify(get_sys_stats())
def run_compile_core(yaml_name, mode='normal', log_mode='w'):
global compile_status
yaml_full_path = os.path.join(CONFIG_DIR, yaml_name)
device_name = get_esphome_device_name(yaml_full_path) or yaml_name.replace('.yaml', '').replace('.yml', '')
current_compile_version = get_esphome_docker_version()
meta_path = os.path.join(CONFIG_DIR, f"{device_name}_firmware.meta")
cmd = f"nice -n 19 docker exec esphome esphome compile /config/{yaml_name}"
if mode == 'clean':
cmd = f"nice -n 19 docker exec esphome esphome clean /config/{yaml_name} && {cmd}"
with open(LOG_FILE, log_mode, encoding="utf-8") as log_f:
log_f.write(f"\n\n=================== [{datetime.now().strftime('%H:%M:%S')}] Processing: {yaml_name} ===================\n")
log_f.flush()
process = subprocess.Popen(cmd, shell=True, stdout=log_f, stderr=subprocess.STDOUT, text=True)
process.wait()
if process.returncode == 0:
build_dir = os.path.join(CONFIG_DIR, ".esphome", "build", device_name, ".pioenvs", device_name)
has_firmware = False
ota_src = os.path.join(build_dir, "firmware.bin")
if os.path.exists(ota_src):
os.system(f"cp '{ota_src}' '{os.path.join(CONFIG_DIR, f'{device_name}_firmware.bin')}'")
has_firmware = True
fac_src = os.path.join(build_dir, "firmware.factory.bin")
if os.path.exists(fac_src):
os.system(f"cp '{fac_src}' '{os.path.join(CONFIG_DIR, f'{device_name}_factory.bin')}'")
has_firmware = True
if has_firmware:
with open(meta_path, 'w', encoding='utf-8') as meta_f: meta_f.write(current_compile_version)
return True, "success"
with open(meta_path, 'w', encoding='utf-8') as meta_f: meta_f.write("failed")
return False, ""
def compile_all_worker():
global compile_status
try:
yaml_files = [f for f in os.listdir(CONFIG_DIR) if is_valid_esphome_yaml(f)]
if not yaml_files:
compile_status = {"status": "idle", "message": "No YAML files found."}
return
total = len(yaml_files)
with open(LOG_FILE, 'w', encoding='utf-8') as log_f:
log_f.write(f"=== Batch Update Started at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ===\n")
for idx, yaml_file in enumerate(yaml_files, 1):
compile_status = {"status": "compiling", "message": f"🤖 Batch updating ({idx}/{total}): {yaml_file}"}
run_compile_core(yaml_file, mode='normal', log_mode='a')
compile_status = {"status": "idle", "message": "🎉 All batchupdate operations completed successfully."}
except Exception as e:
compile_status = {"status": "idle", "message": f"❌ Error: {str(e)}"}
finally:
with open(LOG_FILE, 'a', encoding='utf-8') as log_f: log_f.write("\n[DONE]\n")
@app.route('/')
def index():
files = []
if os.path.exists(CONFIG_DIR):
files = [f for f in os.listdir(CONFIG_DIR) if is_valid_esphome_yaml(f)]
return render_template('esphome_index.html', files=sorted(files))
@app.route('/start', methods=['POST'])
def start_compile():
global compile_status
if compile_status["status"] == "compiling": return jsonify({"result": "busy"})
data = request.json
yaml_name = data.get('yaml')
mode = data.get('mode', 'normal')
compile_status = {"status": "compiling", "message": f"⚡ Active updating: {yaml_name}..."}
import threading
def worker():
global compile_status
success, _ = run_compile_core(yaml_name, mode, 'w')
if success: compile_status = {"status": "idle", "message": "🟢 Firmware compiled successfully!"}
else: compile_status = {"status": "idle", "message": "🔴 Compilation failed."}
with open(LOG_FILE, 'a', encoding='utf-8') as log_f: log_f.write("\n[DONE]\n")
threading.Thread(target=worker).start()
return jsonify({"result": "started"})
@app.route('/start-all', methods=['POST'])
def start_compile_all():
global compile_status
if compile_status["status"] == "compiling": return jsonify({"result": "busy"})
compile_status = {"status": "compiling", "message": "🚀 Batch update task sequence starting..."}
import threading
threading.Thread(target=compile_all_worker).start()
return jsonify({"result": "started"})
@app.route('/status')
def get_status(): return jsonify(compile_status)
@app.route('/stream-logs')
def stream_logs():
def generate():
if not os.path.exists(LOG_FILE):
with open(LOG_FILE, 'w') as f: f.write("")
with open(LOG_FILE, 'r', encoding='utf-8', errors='ignore') as f:
yield f"data: {f.read()}\n\n"
while True:
line = f.readline()
if line:
yield f"data: {line.strip()}\n\n"
if "[DONE]" in line: break
else: time.sleep(0.1)
return Response(generate(), mimetype='text/event-stream')
@app.route('/get-last-log')
def get_last_log():
log_content = "No log output available."
if os.path.exists(LOG_FILE):
with open(LOG_FILE, 'r', encoding='utf-8', errors='ignore') as f: log_content = f.read()
return jsonify({"log": log_content})
@app.route('/get-firmware-history')
def get_firmware_history():
if not os.path.exists(CONFIG_DIR): return jsonify([])
docker_ver = get_esphome_docker_version()
links_data = load_links_file()
devices_dict = {}
for filename in os.listdir(CONFIG_DIR):
if filename.endswith('_firmware.bin') or filename.endswith('_factory.bin'):
is_factory = filename.endswith('_factory.bin')
device_raw_name = filename.replace('_factory.bin', '').replace('_firmware.bin', '')
full_path = os.path.join(CONFIG_DIR, filename)
mtime = os.path.getmtime(full_path)
if device_raw_name not in devices_dict:
devices_dict[device_raw_name] = {
"device_name": device_raw_name, "time_raw": 0, "ota_url": "",
"factory_url": "", "version": "", "device_url": links_data.get(device_raw_name, ""), "is_failed": False
}
if mtime > devices_dict[device_raw_name]["time_raw"]:
devices_dict[device_raw_name]["time_raw"] = mtime
devices_dict[device_raw_name]["time"] = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S')
if is_factory: devices_dict[device_raw_name]["factory_url"] = f"/download/{filename}"
else: devices_dict[device_raw_name]["ota_url"] = f"/download/{filename}"
for dev_name, info in devices_dict.items():
meta_path = os.path.join(CONFIG_DIR, f"{dev_name}_firmware.meta")
version = ""
if os.path.exists(meta_path):
try:
with open(meta_path, 'r', encoding='utf-8', errors='ignore') as meta_f: version = meta_f.read().strip()
except: pass
info["is_failed"] = (version == "failed")
if info["is_failed"]: info["version"] = "编译失败"
else:
if not version or version in ["3", "v3"]: version = docker_ver
version = version.replace('vVersion:', '').replace('Version:', '').replace('version:', '').strip()
if version.lower().startswith('v'): version = version[1:]
info["version"] = f"v{version.strip()}"
return jsonify(sorted(list(devices_dict.values()), key=lambda x: x['time_raw'], reverse=True))
@app.route('/download/<filename>')
def download_file(filename):
from flask import send_from_directory
return send_from_directory(CONFIG_DIR, filename, as_attachment=True)
@app.route('/save-device-link', methods=['POST'])
def save_device_link():
data = request.json
dev_name = data.get('device_name')
url = data.get('url', '').strip()
links = load_links_file()
if url: links[dev_name] = url
else:
if dev_name in links: del links[dev_name]
save_links_file(links)
return jsonify({"result": "success"})
@app.route('/container-status')
def container_status():
try:
res = subprocess.run("docker inspect -f '{{.State.Running}}' esphome", shell=True, capture_output=True, text=True)
if res.stdout.strip() == 'true': return jsonify({"online": True})
except: pass
return jsonify({"online": False})
@app.route('/check-versions')
def check_versions():
local_ver = get_esphome_docker_version()
return jsonify({"local": local_ver, "latest": local_ver, "update_available": False})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5500)2.2 编辑esphome_index.html
代码超长点击展开代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>ESPHome Online Build Platform</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
/* 页面骨架:强制一屏流 */
html, body { height: 100%; margin: 0; padding: 0; background-color: #f8f9fa; overflow: hidden; }
.main-wrapper { display: flex; flex-direction: column; height: 100vh; padding: 12px; gap: 10px; }
/* 顶部状态栏 */
.header-box { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); padding: 8px 16px; display: flex; justify-content: space-between; align-items: center; }
.monitor-tag { font-size: 0.75rem; font-family: monospace; display: flex; align-items: center; gap: 4px; background-color: #f8f9fa; padding: 1px 6px; border-radius: 4px; border: 1px solid #e4e7ed; }
.monitor-progress { width: 30px; height: 4px; border-radius: 2px; background-color: #e4e7ed; overflow: hidden; display: inline-block; }
/* 主布局弹性容器 */
.content-container { display: flex; flex: 1; gap: 12px; min-height: 0; }
.panel-left { width: 390px; display: flex; flex-direction: column; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); min-height: 0; }
.panel-right { flex: 1; display: flex; flex-direction: column; background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.04); padding: 14px; min-height: 0; }
.panel-title { font-size: 0.88rem; font-weight: bold; color: #2f3542; padding: 10px 14px; border-bottom: 1px solid #f1f2f6; display: flex; justify-content: space-between; align-items: center; }
.scroll-area { flex: 1; overflow-y: auto; padding: 12px 10px; }
/* 📦 升级版固件卡片样式:整体外框色彩渲染 */
.history-item {
background: #fff;
border: 1.5px solid #e4e7ed; /* 略微加粗边框,让色彩边缘更明显 */
border-left: 5px solid #10ac84; /* 左侧加粗主条 */
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 12px;
box-shadow: 0 2px 5px rgba(0,0,0,0.02);
position: relative;
transition: all 0.2s ease;
}
/* 编译失败时的卡片整体变红 */
.history-item.failed-card {
background-color: #fff5f5 !important;
border-color: #ffa39e !important;
border-left-color: #ee5253 !important;
}
/* 第一行:固件名 + 链接区 */
.line-1 { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.device-title-zone { display: flex; align-items: center; gap: 6px; }
.device-pure-name { font-size: 0.95rem; font-weight: bold; color: #2f3542; }
/* 右侧独立出来的多链接渲染区 */
.links-jump-zone { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; justify-content: flex-end; max-width: 210px; }
.custom-jump-link { font-size: 0.8rem; color: #0984e3; text-decoration: none; background: #fff; padding: 1px 6px; border-radius: 3px; font-weight: 500; border: 1px solid #b3e5fc; }
.custom-jump-link:hover { background: #b3e5fc; color: #0288d1; }
.btn-edit-trigger { color: #9c88ff; font-size: 0.78rem; cursor: pointer; margin-left: 6px; user-select: none; font-weight: 500; }
.btn-edit-trigger:hover { color: #6c5ce7; text-decoration: underline; }
/* 第二行:内核与编译时间 */
.line-2 { font-size: 0.75rem; color: #747d8c; font-family: monospace; margin-bottom: 6px; display: flex; justify-content: space-between; border-bottom: 1px dashed rgba(0,0,0,0.06); padding-bottom: 4px; }
/* 第三行:下载按钮行(Factory在前,OTA在后) */
.btn-classic-fac { padding: 4px 10px; font-size: 0.78rem; font-weight: bold; border-radius: 4px; border: 1px solid #17a2b8; background-color: #fff; }
.btn-classic-fac:hover { background-color: #17a2b8; color: #fff !important; }
.btn-classic-dl { padding: 4px 12px; font-size: 0.78rem; font-weight: bold; display: flex; align-items: center; justify-content: center; gap: 4px; flex: 1; border-radius: 4px; }
/* 🔀 分离左右两块的编辑表单容器 */
.edit-form-box { background: #fff; padding: 8px; border-radius: 6px; margin-top: 6px; border: 1px solid #ced6e0; box-shadow: inset 0 1px 3px rgba(0,0,0,0.05); }
.link-row-item { display: flex; gap: 4px; margin-bottom: 4px; align-items: center; }
.link-row-item input { font-size: 0.75rem; height: 25px; padding: 1px 4px; font-family: monospace; }
.btn-add-row { font-size: 0.7rem; padding: 0px 4px; color: #0984e3; cursor: pointer; border: none; background: none; text-decoration: underline; }
.btn-add-row:hover { color: #0288d1; }
.btn-del-row { font-size: 0.75rem; color: #ee5253; cursor: pointer; border: none; background: none; padding: 0 2px; }
/* 右侧终端控制台 */
.console-wrapper { flex: 1; display: flex; flex-direction: column; min-height: 0; margin-top: 10px; }
#console {
flex: 1; background-color: #1e1e2e; color: #cdd6f4; font-family: "Courier New", Courier, monospace; font-size: 12px;
padding: 12px; border-radius: 6px; overflow-y: auto; white-space: pre-wrap; box-shadow: inset 0 2px 6px rgba(0,0,0,0.3); margin: 0;
}
</style>
</head>
<body>
<div class="main-wrapper">
<div class="header-box">
<div class="d-flex align-items-center gap-3">
<h5 class="m-0 fw-bold text-dark" style="font-size: 1.05rem;">🛠️ Rose ESPHome Platform</h5>
<div class="d-flex gap-2">
<div class="monitor-tag"><span>CPU:</span><span id="mon-cpu-text" class="fw-bold">0%</span><div class="monitor-progress"><div id="mon-cpu-bar" class="bg-primary h-100"></div></div></div>
<div class="monitor-tag"><span>RAM:</span><span id="mon-mem-text" class="fw-bold">0%</span><div class="monitor-progress"><div id="mon-mem-bar" class="bg-success h-100"></div></div></div>
<div class="monitor-tag"><span class="text-success" style="font-size:0.65rem;">▼</span><span id="mon-rx-text" class="fw-bold">0K/s</span></div>
<div class="monitor-tag"><span class="text-danger" style="font-size:0.65rem;">▲</span><span id="mon-tx-text" class="fw-bold">0K/s</span></div>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<span id="container-badge" class="badge bg-secondary py-1 px-2" style="font-size: 0.75rem;">检测中...</span>
<span id="local-badge" class="badge bg-success py-1 px-2" style="font-size: 0.75rem;"></span>
<span id="latest-badge" class="badge bg-info text-white py-1 px-2" style="font-size: 0.75rem;"></span>
</div>
</div>
<div class="content-container">
<div class="panel-left">
<div class="panel-title">
<span>📦 Compiled History & Devices</span>
<button class="btn btn-xs btn-outline-secondary py-0 px-2" style="font-size:0.75rem;" onclick="fetchHistory()">刷新</button>
</div>
<div class="scroll-area" id="history-list">
<div class="text-center text-muted py-4">正在扫描固件资产...</div>
</div>
</div>
<div class="panel-right">
<div class="row g-2 align-items-center mb-2">
<div class="col-auto"><label class="form-label fw-bold text-secondary m-0" style="font-size:0.85rem;">Select Configuration File:</label></div>
<div class="col">
<select class="form-select form-select-sm font-monospace" id="yaml-select">
{% for file in files %}
<option value="{{ file }}">{{ file }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-sm-6"><button class="btn btn-primary btn-sm w-100 py-2 fw-bold" id="btn-start" onclick="startCompile('normal')">Start Build</button></div>
<div class="col-sm-4"><button class="btn btn-warning btn-sm w-100 py-2 text-white fw-bold" id="btn-clean" onclick="startCompile('clean')">Clean Build</button></div>
<div class="col-sm-2"><button class="btn btn-secondary btn-sm w-100 py-2" onclick="fetchLogs()">View Log</button></div>
</div>
<div class="mb-2">
<button class="btn btn-danger btn-sm w-100 py-2 fw-bold" id="btn-all" onclick="startCompileAll()">🚀 Update & Build All Devices (Sequential Serverless Execution)</button>
</div>
<div class="console-wrapper">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-secondary fw-bold" style="font-size:0.8rem;">Status: <span id="status-text" class="text-primary">Waiting...</span></span>
</div>
<div id="console">Console output layer...</div>
</div>
</div>
</div>
</div>
<script>
let currentLogSource = null; let logBuffer = ""; let logTimer = null;
let localDevicesData = [];
function fixHttpUrl(url) {
if (!url) return "";
let t = url.trim();
if (/^https?:\/\//i.test(t)) return t;
return "http://" + t;
}
function updateSystemStats() {
fetch('/sys-stats').then(res => res.json()).then(data => {
document.getElementById('mon-cpu-text').innerText = data.cpu + '%';
document.getElementById('mon-cpu-bar').style.width = data.cpu + '%';
document.getElementById('mon-mem-text').innerText = data.mem + '%';
document.getElementById('mon-mem-bar').style.width = data.mem + '%';
document.getElementById('mon-rx-text').innerText = data.rx >= 1 ? data.rx.toFixed(1) + 'M/s' : (data.rx * 1024).toFixed(0) + 'K/s';
document.getElementById('mon-tx-text').innerText = data.tx >= 1 ? data.tx.toFixed(1) + 'M/s' : (data.tx * 1024).toFixed(0) + 'K/s';
}).catch(()=>{});
}
function checkContainerStatus() {
fetch('/container-status').then(res => res.json()).then(data => {
const b = document.getElementById('container-badge');
if(data.online) { b.className="badge bg-success py-1 px-2"; b.innerText="● 引擎在线"; }
else { b.className="badge bg-danger py-1 px-2"; b.innerText="● 引擎离线"; }
});
}
function checkVersions() {
fetch('/check-versions').then(res => res.json()).then(data => {
const l = document.getElementById('local-badge'); l.innerText = "Local: ESPHome " + data.local;
const r = document.getElementById('latest-badge'); r.innerText = "Latest: ESPHome " + data.latest;
});
}
// 重新设计的卡片渲染器:支持全边界主题色浸润
function renderHistoryList(data) {
const container = document.getElementById('history-list');
if(data.length === 0) { container.innerHTML = '<div class="text-center text-muted py-3">No configuration firmware generated yet.</div>'; return; }
container.innerHTML = data.map(item => {
const dot = item.is_failed ? "🔴" : "🟢";
let linksHtml = "";
let rawUrl = item.device_url ? item.device_url.trim() : "";
// 调色盘定义组(针对正常状态设备)
// [主色调线, 超浅透明背景背景色]
let colorThemes = [
{ border: "#10ac84", bg: "rgba(16, 172, 132, 0.03)" }, // 翡翠绿
{ border: "#2e86de", bg: "rgba(46, 134, 222, 0.03)" }, // 皇家蓝
{ border: "#8854d0", bg: "rgba(136, 84, 208, 0.03)" }, // 迷幻紫
{ border: "#ff9f43", bg: "rgba(255, 159, 67, 0.03)" }, // 暖阳橙
{ border: "#00d2d3", bg: "rgba(0, 210, 211, 0.03)" }, // 青色
{ border: "#ef5777", bg: "rgba(239, 87, 119, 0.03)" } // 蔷薇红
];
let colorSeed = item.device_name.charCodeAt(0) + item.device_name.charCodeAt(item.device_name.length - 1);
let theme = colorThemes[colorSeed % colorThemes.length];
if (rawUrl) {
let segments = rawUrl.split('|');
segments.forEach((seg) => {
if(!seg.trim()) return;
let parts = seg.split(',');
let showName = parts[0] ? parts[0].trim() : "Link";
let linkAddress = parts[1] ? parts[1].trim() : parts[0].trim();
let verifiedUrl = fixHttpUrl(linkAddress);
// 动态让小标签也带上对应主题边框色,形成呼应
linksHtml += `<a href="${verifiedUrl}" target="_blank" class="custom-jump-link" style="border-color:${theme.border}; color:${theme.border}; background: #fff;" title="${verifiedUrl}">${showName} ↗</a>`;
});
}
// 动态决定样式串
let inlineStyle = item.is_failed
? ""
: `border-color: ${theme.border}; border-left-color: ${theme.border}; background-color: ${theme.bg};`;
return `
<div class="history-item ${item.is_failed ? 'failed-card' : ''}" id="card-${item.device_name}" style="${inlineStyle}">
<div class="line-1">
<div class="device-title-zone">
<span>${dot}</span>
<span class="device-pure-name" style="${item.is_failed ? '' : 'color:' + theme.border}">${item.device_name}</span>
</div>
<div class="links-jump-zone">
${linksHtml}
<span class="btn-edit-trigger" style="${item.is_failed ? '' : 'color:' + theme.border}" onclick="showLinkEdit('${item.device_name}')">[编辑]</span>
</div>
</div>
<div id="edit-box-container-${item.device_name}"></div>
<div class="line-2">
<span>内核: ${item.version}</span>
<span>时间: ${item.time || '--'}</span>
</div>
${item.is_failed ? '' : `
<div class="d-flex gap-2 mt-1">
${item.factory_url?`<a href="${item.factory_url}" class="btn btn-classic-fac" style="border-color:${theme.border}; color:${theme.border}">Factory</a>`:''}
${item.ota_url?`<a href="${item.ota_url}" class="btn btn-classic-dl text-white" style="background-color:${theme.border}"><svg style="width:12px;height:12px" viewBox="0 0 24 24"><path fill="currentColor" d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"/></svg> Download Bin (OTA)</a>`:''}
</div>`}
</div>`;
}).join('');
}
function fetchHistory() {
fetch('/get-firmware-history').then(res => res.json()).then(data => {
localDevicesData = data;
renderHistoryList(localDevicesData);
});
}
// 全新重构的双框分栏编辑区
function showLinkEdit(name) {
const item = localDevicesData.find(d => d.device_name === name);
if(!item) return;
let currentRaw = item.device_url || "";
const box = document.getElementById(`edit-box-container-${name}`);
let rowsHtml = "";
if(currentRaw.trim()) {
let segments = currentRaw.split('|');
segments.forEach((seg, idx) => {
if(!seg.trim()) return;
let parts = seg.split(',');
let title = parts[0] ? parts[0].trim() : "";
let val = parts[1] ? parts[1].trim() : "";
if(!val && title && (title.includes('.') || title.includes(':'))) { val = title; title = "访问"; }
rowsHtml += createFormRowHtml(name, title, val);
});
}
if(!rowsHtml) { rowsHtml = createFormRowHtml(name, "访问", ""); }
box.innerHTML = `
<div class="edit-form-box">
<div id="form-rows-list-${name}">
${rowsHtml}
</div>
<div class="d-flex justify-content-between align-items-center mt-2 pt-1" style="border-top:1px solid #e4e7ed;">
<span class="btn-add-row" onclick="addBlankRowToForm('${name}')">+ 增加一行</span>
<div class="d-flex gap-1">
<button class="btn btn-success btn-sm py-0 px-2 fw-bold" style="font-size:0.75rem; height:24px;" onclick="saveLink('${name}')">保存</button>
<button class="btn btn-secondary btn-sm py-0 px-2" style="font-size:0.75rem; height:24px;" onclick="cancelEdit('${name}')">取消</button>
</div>
</div>
</div>`;
}
function createFormRowHtml(name, title, value) {
return `
<div class="link-row-item">
<input type="text" class="form-control form-control-sm item-title-input" style="width: 75px;" value="${title}" placeholder="别名">
<span class="text-muted">:</span>
<input type="text" class="form-control form-control-sm item-value-input flex-fill" value="${value}" placeholder="IP 或 域名链接">
<button class="btn-del-row" onclick="this.parentElement.remove()" title="删除此行">×</button>
</div>`;
}
function addBlankRowToForm(name) {
const container = document.getElementById(`form-rows-list-${name}`);
let div = document.createElement('div');
div.className = "link-row-item";
div.innerHTML = `
<input type="text" class="form-control form-control-sm item-title-input" style="width: 75px;" value="" placeholder="名称">
<span class="text-muted">:</span>
<input type="text" class="form-control form-control-sm item-value-input flex-fill" value="" placeholder="10.0.0.xx">
<button class="btn-del-row" onclick="this.parentElement.remove()">×</button>`;
container.appendChild(div);
}
function cancelEdit(name) {
document.getElementById(`edit-box-container-${name}`).innerHTML = "";
}
function saveLink(name) {
const container = document.getElementById(`form-rows-list-${name}`);
const items = container.querySelectorAll('.link-row-item');
let serializedArr = [];
items.forEach(row => {
let t = row.querySelector('.item-title-input').value.trim();
let v = row.querySelector('.item-value-input').value.trim();
if(!t && !v) return;
if(!t) t = "Link";
serializedArr.push(`${t},${v}`);
});
const newRawValue = serializedArr.join('|');
const itemIndex = localDevicesData.findIndex(d => d.device_name === name);
if(itemIndex !== -1) { localDevicesData[itemIndex].device_url = newRawValue; }
renderHistoryList(localDevicesData);
fetch('/save-device-link', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({device_name: name, url: newRawValue})
}).catch(()=>{});
}
function startCompile(mode) {
const yaml = document.getElementById('yaml-select').value; setButtons(true);
fetch('/start', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({yaml, mode})})
.then(res=>res.json()).then(data=>{ if(data.result==='started'){ openLogStream(); loopStatus(); }else{ setButtons(false); } });
}
function startCompileAll() {
if(!confirm('确定依次全量编译吗?')) return; setButtons(true);
fetch('/start-all', {method:'POST', headers:{'Content-Type':'application/json'}}).then(()=> { openLogStream(); loopStatus(); });
}
function openLogStream() {
if(currentLogSource) currentLogSource.close();
const el = document.getElementById('console'); el.innerHTML = "[正在挂载流式日志输出管道...]\n";
logBuffer = ""; currentLogSource = new EventSource('/stream-logs');
currentLogSource.onmessage = function(e) {
if(e.data === '[DONE]') { currentLogSource.close(); fetchHistory(); return; }
logBuffer += e.data + "\n";
if(!logTimer) { logTimer = setTimeout(() => { el.innerHTML += logBuffer; el.scrollTop = el.scrollHeight; logBuffer = ""; logTimer = null; }, 40); }
};
}
function fetchLogs() { fetch('/get-last-log').then(res=>res.json()).then(data=>{ const el=document.getElementById('console'); el.innerHTML=data.log; el.scrollTop=el.scrollHeight; }); }
function setButtons(d) { document.getElementById('btn-start').disabled=d; document.getElementById('btn-clean').disabled=d; document.getElementById('btn-all').disabled=d; }
function loopStatus() {
fetch('/status').then(res=>res.json()).then(data=>{
document.getElementById('status-text').innerHTML = data.message;
if(data.status==='compiling') { setButtons(true); setTimeout(loopStatus, 1000); } else { setButtons(false); fetchHistory(); }
});
}
updateSystemStats(); setInterval(updateSystemStats, 2000);
checkContainerStatus(); setInterval(checkContainerStatus, 5000);
checkVersions(); fetchHistory(); fetchLogs();
</script>
</body>
</html>3.安装及测试
3.1 安装 Python 3 环境及面板依赖
由于面板后端是用 Python 的 Flask 框架编写的,且需要读取系统硬件状态,必须先补全宿主机的 Python 环境。
在宿主机终端运行以下命令:
# 1. 更新系统包索引并安装 Python3 和 Pip 包管理器
apt update && apt apt install -y python3-pip
# 2. 安装面板核心依赖(Flask 网页框架 与 psutil 硬件监控库)
pip3 install flask psutil3.2 首次手动前台启动(测试与调优)
在正式让它长期在后台跑之前,先在终端前台启动一次,方便观察有没有路径报错或权限问题。
# 1. 切换到代码所在目录
cd /root
# 2. 用 Python 3 启动后端脚本
python3 esphome_panel.py💡 如何判断启动成功?
如果终端没有抛出红色报错,并且最后输出类似 Running on all addresses (0.0.0.0) 和 Running on http://127.0.0.1:5500,说明后端已经成功在 5500 端口上监听。
3.3 本地跑通验证测试
保持刚才启动的终端别关,在同一局域网的电脑上打开浏览器,访问:
http://你的服务器局域网IP:5500
检查点 A(设备列表):看左侧是否成功加载出了你挂载目录下的那些 .yaml 设备配置文件。
检查点 B(监控状态):看顶部状态栏的 CPU、内存、Docker 状态(应该显示绿色的 Running)是否开始动态跳动。
检查点 C(功能测试):任选一个设备,点击“标准编译更新”,观察右侧黑色控制台是否开始滚动输出 Docker 编译日志。
验证一切正常后,回到终端按 Ctrl + C 结束这个前台进程,接下来把它托管到后台。
4.将面板注册为系统服务(开机自启与后台托管)
为了不用每次都开着终端窗口,我们需要把面板打包成 Linux 的 Systemd 系统服务,让它在后台默默干活,且跟着服务器一起开机自启。
4.1 创建服务配置文件
在终端执行以下命令,直接新建服务文件:
nano /etc/systemd/system/esphome-panel.service
将以下配置内容完整粘贴进去:
Ini, TOML
[Unit]
Description=ESPHome Online Compile Panel Service
# 确保在网络和 Docker 容器都准备好之后再启动面板
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
User=root
# 🎯 指定代码和 html 文件所在的实际目录
WorkingDirectory=/root
# 🎯 执行启动的完整命令
ExecStart=/usr/bin/python3 /root/esphome_panel.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target保存并退出(nano 编辑器下按 Ctrl + O 回车保存,再按 Ctrl + X 退出)。
4.2 激活并拉起后台服务
在终端依次敲入以下三行命令,完成服务的注册与启动:
# 1. 刷新系统服务列表,让系统认出刚刚新建的 esphome-panel
systemctl daemon-reload
# 2. 设置开机自启
systemctl enable esphome-panel.service
# 3. 立即启动该服务
systemctl start esphome-panel.service5.日常维护快捷命令
服务托管到后台后,以后你只需要用这三行系统命令就能完美控制它:
检查面板在后台活得怎么样:
systemctl status esphome-panel.service
(如果看到绿色的 active (running),说明在后台很健康)修改了代码或者卡死时,重启面板:
systemctl restart esphome-panel.service实时追踪面板后台的最新动作日志:
journalctl -u esphome-panel.service -f -n 50