文章以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.pyesphome_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 psutil

3.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.service

5.日常维护快捷命令

服务托管到后台后,以后你只需要用这三行系统命令就能完美控制它:

检查面板在后台活得怎么样:

systemctl status esphome-panel.service
(如果看到绿色的 active (running),说明在后台很健康)

修改了代码或者卡死时,重启面板:

systemctl restart esphome-panel.service

实时追踪面板后台的最新动作日志:

journalctl -u esphome-panel.service -f -n 50
最后修改:2026 年 06 月 06 日
如果觉得我的文章对你有用,请随意赞赏