基础版本ui完成

This commit is contained in:
2026-02-01 18:52:36 +08:00
parent 9415bde6b9
commit b068b9cf49
2 changed files with 614 additions and 29 deletions

View File

@@ -1,20 +1,22 @@
# ESP8266天气站主程序 # ESP8266天气站主程序
# 首先尝试连接已保存的WiFi失败则启动CaptivePortal进行配置 # 首先尝试连接已保存的WiFi失败则启动CaptivePortal进行配置
import asyncio
import gc import gc
import json import json
import sys import sys
import time import time
import machine import machine
import asyncio
from config import config from config import config
from display import display # 导入液晶屏管理模块 from display import display # 导入液晶屏管理模块
from wifi_manager import wifi_manager from wifi_manager import wifi_manager
def uuid(): def uuid():
return str(machine.unique_id().hex()) return str(machine.unique_id().hex())
def parse_url_params(url): def parse_url_params(url):
# 解析URL中的查询参数返回参数字典 # 解析URL中的查询参数返回参数字典
params = {} params = {}
@@ -30,18 +32,23 @@ def parse_url_params(url):
return params return params
async def post_parse(request): async def post_parse(request):
if request.method != "POST": if request.method != "POST":
raise Exception("invalid request") raise Exception("invalid request")
content_length = int(request.headers["Content-Length"]) content_length = int(request.headers["Content-Length"])
return (await request.read(content_length)).decode() return (await request.read(content_length)).decode()
async def json_response(request, data): async def json_response(request, data):
await request.write("HTTP/1.1 200 OK\r\n") await request.write("HTTP/1.1 200 OK\r\n")
await request.write("Content-Type: application/json; charset=utf-8\r\n\r\n") await request.write("Content-Type: application/json; charset=utf-8\r\n\r\n")
await request.write(data) await request.write(data)
CREDENTIALS = ('admin', config.get('web_password', 'admin'))
CREDENTIALS = ("admin", config.get("web_password", "admin"))
def authenticate(credentials): def authenticate(credentials):
async def fail(request): async def fail(request):
await request.write("HTTP/1.1 401 Unauthorized\r\n") await request.write("HTTP/1.1 401 Unauthorized\r\n")
@@ -51,31 +58,37 @@ def authenticate(credentials):
def decorator(func): def decorator(func):
async def wrapper(request): async def wrapper(request):
from ubinascii import a2b_base64 as base64_decode from ubinascii import a2b_base64 as base64_decode
header = request.headers.get('Authorization', None)
header = request.headers.get("Authorization", None)
if header is None: if header is None:
return await fail(request) return await fail(request)
# Authorization: Basic XXX # Authorization: Basic XXX
kind, authorization = header.strip().split(' ', 1) kind, authorization = header.strip().split(" ", 1)
if kind != "Basic": if kind != "Basic":
return await fail(request) return await fail(request)
authorization = base64_decode(authorization.strip()) \ authorization = (
.decode('ascii') \ base64_decode(authorization.strip()).decode("ascii").split(":")
.split(':') )
if list(credentials) != list(authorization): if list(credentials) != list(authorization):
return await fail(request) return await fail(request)
return await func(request) return await func(request)
return wrapper return wrapper
return decorator return decorator
# /status: 获取系统状态 # /status: 获取系统状态
@authenticate(credentials=CREDENTIALS) @authenticate(credentials=CREDENTIALS)
async def sys_status(request): async def sys_status(request):
await json_response(request, await json_response(
json.dumps({ request,
json.dumps(
{
"time": "{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format( "time": "{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format(
*time.localtime() *time.localtime()
), ),
@@ -84,28 +97,35 @@ async def sys_status(request):
"uuid": uuid(), "uuid": uuid(),
"platform": str(sys.platform), "platform": str(sys.platform),
"version": str(sys.version), "version": str(sys.version),
}) }
),
) )
# /lcd: 获取LCD状态 # /lcd: 获取LCD状态
@authenticate(credentials=CREDENTIALS) @authenticate(credentials=CREDENTIALS)
async def lcd_status(request): async def lcd_status(request):
# 返回LCD状态 # 返回LCD状态
await json_response(request, await json_response(
json.dumps({ request,
json.dumps(
{
"ready": display.is_ready(), "ready": display.is_ready(),
"brightness": display.brightness(), "brightness": display.brightness(),
"ui_type": display.ui_type, "ui_type": display.ui_type,
"data": display.ui_data, "data": display.ui_data,
}) }
),
) )
# /config: 获取当前配置 # /config: 获取当前配置
@authenticate(credentials=CREDENTIALS) @authenticate(credentials=CREDENTIALS)
async def config_get(request): async def config_get(request):
# 返回所有配置项 # 返回所有配置项
await json_response(request, json.dumps(config.config_data)) await json_response(request, json.dumps(config.config_data))
# /lcd/set: 设置LCD状态 # /lcd/set: 设置LCD状态
@authenticate(credentials=CREDENTIALS) @authenticate(credentials=CREDENTIALS)
async def lcd_set(request): async def lcd_set(request):
@@ -122,6 +142,7 @@ async def lcd_set(request):
finally: finally:
await json_response(request, json.dumps(ack)) await json_response(request, json.dumps(ack))
# /config/set: 更新配置 # /config/set: 更新配置
# curl -H "Content-Type: application/json" -X POST -d '{"city":"xxx","who":"ami"}' 'http://<url>/config/set' # curl -H "Content-Type: application/json" -X POST -d '{"city":"xxx","who":"ami"}' 'http://<url>/config/set'
@authenticate(credentials=CREDENTIALS) @authenticate(credentials=CREDENTIALS)
@@ -139,6 +160,7 @@ async def config_update(request):
finally: finally:
await json_response(request, json.dumps(ack)) await json_response(request, json.dumps(ack))
# /exec: 执行命令并返回 # /exec: 执行命令并返回
# {"cmd":"import network;R=network.WLAN().config(\"mac\").hex()", "token":"xxx"} # {"cmd":"import network;R=network.WLAN().config(\"mac\").hex()", "token":"xxx"}
@authenticate(credentials=CREDENTIALS) @authenticate(credentials=CREDENTIALS)
@@ -159,6 +181,8 @@ async def eval_cmd(request):
finally: finally:
await json_response(request, json.dumps(ack)) await json_response(request, json.dumps(ack))
# ntp时钟同步 # ntp时钟同步
def sync_ntp_time(): def sync_ntp_time():
import ntptime import ntptime
@@ -187,7 +211,7 @@ async def fetch_weather_data(city=None):
print(f"正在获取{city}天气数据...") print(f"正在获取{city}天气数据...")
# 从配置获取API基础URL默认使用官方API # 从配置获取API基础URL默认使用官方API
url = config.get("weather_api_url", "http://esp.tangofu.com/api/ws2/") url = config.get("weather_api_url", "http://esp.tangofu.com/api/ws2/")
params = {'uuid':uuid(), 'city':city} params = {"uuid": uuid(), "city": city}
# 发送GET请求 # 发送GET请求
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
@@ -211,8 +235,15 @@ async def fetch_weather_data(city=None):
ip = wifi_manager.get_ip() ip = wifi_manager.get_ip()
display.update_ui(city, weather, advice, aqi, lunar, ip, display.update_ui(
envdat={'t':t,'rh':rh,'co2':co2,'pm':pm,'ap':ap}) city,
weather,
advice,
aqi,
lunar,
ip,
envdat={"t": t, "rh": rh, "co2": co2, "pm": pm, "ap": ap},
)
else: else:
print(f"获取天气数据失败,状态码: {response.status}") print(f"获取天气数据失败,状态码: {response.status}")
@@ -282,7 +313,7 @@ async def ui_task():
t0 = time.ticks_ms() t0 = time.ticks_ms()
# 计算当前帧号(1-10),更新动画 # 计算当前帧号(1-10),更新动画
cframe = (F % 10) cframe = F % 10
pic = f"/rom/images/T{cframe}.jpg" pic = f"/rom/images/T{cframe}.jpg"
display.show_jpg(pic, 160, 160) display.show_jpg(pic, 160, 160)
@@ -308,16 +339,19 @@ async def ui_task():
except Exception as e: except Exception as e:
print(f"动画任务初始化失败: {e}") print(f"动画任务初始化失败: {e}")
def cb_progress(data): def cb_progress(data):
if isinstance(data, bytes): if isinstance(data, bytes):
if data == b'/': if data == b"/":
display.portal_info('load iwconfig page ') display.portal_info("load iwconfig page ")
elif data == b'/login': elif data == b"/login":
display.portal_info('WiFi connecting ... ') display.portal_info("WiFi connecting ... ")
elif isinstance(data, str): elif isinstance(data, str):
display.message(data, 19, 204) display.message(data, 19, 204)
if data: print(f'progress: {str(data)}') if data:
print(f"progress: {str(data)}")
def start(): def start():
# 初始化液晶屏 # 初始化液晶屏
@@ -328,8 +362,9 @@ def start():
if not wifi_manager.connect(cb_progress): if not wifi_manager.connect(cb_progress):
gc.collect() gc.collect()
from captive_portal import CaptivePortal from captive_portal import CaptivePortal
portal = CaptivePortal() portal = CaptivePortal()
display.portal_win(portal.essid.decode('ascii')) display.portal_win(portal.essid.decode("ascii"))
portal.start(cb_progress) portal.start(cb_progress)
# just reboot # just reboot
machine.reset() machine.reset()
@@ -343,6 +378,7 @@ def start():
naw = Nanoweb() naw = Nanoweb()
# website top directory # website top directory
naw.STATIC_DIR = "/rom/www" naw.STATIC_DIR = "/rom/www"
naw.INDEX_FILE = "/rom/www/index.html"
# Declare route from a dict # Declare route from a dict
naw.routes = { naw.routes = {

549
src/rom/www/index.html Normal file
View File

@@ -0,0 +1,549 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WS2桌面气象站</title>
<link rel="stylesheet" href="./css/micro.css" />
<style>
/* 只保留micro.css中没有的特殊样式 */
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.nav-tabs {
display: flex;
border-bottom: 1px solid #ddd;
margin-bottom: 20px;
}
.nav-tab {
padding: 10px 15px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.nav-tab.active {
border-bottom-color: #3498db;
font-weight: bold;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.gauge-container {
text-align: center;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="card">
<div class="header">
<h1>WS2桌面气象站</h1>
<button class="btn" id="logout-btn">退出</button>
</div>
<div class="alert hidden" id="message"></div>
<!-- 导航选项卡 -->
<div class="nav-tabs">
<div class="nav-tab active" data-tab="status">设备状态</div>
<div class="nav-tab" data-tab="display">屏幕显示</div>
<div class="nav-tab" data-tab="config">系统配置</div>
<div class="nav-tab" data-tab="about">关于</div>
</div>
<!-- 设备状态选项卡 -->
<div id="status-tab" class="tab-content active">
<div class="gauge-container">
<div id="memory-gauge"></div>
</div>
<h3 class="mt-3">系统信息</h3>
<div class="status-grid">
<div>
<strong>时间:</strong> <span data-bind="time">-</span>
</div>
<div>
<strong>运行时间:</strong>
<span data-bind="uptime">-</span>
</div>
<div>
<strong>可用内存:</strong>
<span data-bind="memory">-</span>
</div>
<div>
<strong>UUID:</strong> <span data-bind="uuid">-</span>
</div>
<div>
<strong>平台:</strong>
<span data-bind="platform">-</span>
</div>
<div>
<strong>版本:</strong>
<span data-bind="version">-</span>
</div>
</div>
<button class="btn" id="refresh-status-btn">刷新状态</button>
</div>
<!-- 屏幕显示选项卡 -->
<div id="display-tab" class="tab-content">
<h3>LCD状态</h3>
<div class="row">
<div class="col">
<div class="list">
<div class="list-item">
<strong>就绪状态:</strong>
<span id="lcd-ready-display">-</span>
<span
id="lcd-ready-badge-display"
class="badge badge-warning"
></span>
</div>
<div class="list-item">
<strong>当前亮度:</strong>
<span id="lcd-brightness-display">-</span>%
</div>
<div class="list-item">
<strong>UI类型:</strong>
<span id="lcd-ui-type-display">-</span>
</div>
<div class="list-item">
<strong>数据内容:</strong>
<span id="lcd-data-display">-</span>
</div>
</div>
</div>
</div>
<h3 class="mt-3">显示设置</h3>
<div class="form-group">
<label class="form-label">亮度 (0-100)</label>
<input
type="range"
id="brightness-slider"
min="0"
max="100"
class="form-control"
style="padding:10px 0"
/>
<div>当前值: <span id="brightness-value">-</span></div>
</div>
<div class="form-group">
<label class="form-label">UI类型</label>
<select id="ui-type-select" class="form-control">
<option value="default">太空人天气时钟</option>
<option value="album">电子相册</option>
</select>
</div>
<button class="btn btn-success" id="apply-display-btn">
应用设置
</button>
</div>
<!-- 系统配置选项卡 -->
<div id="config-tab" class="tab-content">
<h3>天气站配置</h3>
<div class="form-group">
<label class="form-label">城市</label>
<input
type="text"
id="city-input"
class="form-control"
placeholder="例如:北京"
/>
<small class="text-muted">
可输入城市名称或城市ID<a
href="https://mapopen-website-wiki.cdn.bcebos.com/cityList/weather_district_id.csv"
target="_blank"
>查看城市ID列表</a
>
</small>
</div>
<div class="form-group">
<label class="form-label">自动熄屏时间</label>
<input
type="time"
id="screen-timeout-input"
class="form-control"
/>
<small class="text-muted">留空表示不自动熄屏</small>
</div>
<button class="btn btn-success" id="save-config-btn">
保存配置
</button>
<h3 class="mt-3">当前配置</h3>
<table class="table" id="current-config">
<tr>
<th>配置项</th>
<th></th>
</tr>
</table>
</div>
<!-- 关于选项卡 -->
<div id="about-tab" class="tab-content">
<h3>关于</h3>
<p>
WS2是一款基于ESP8266的桌面气象站能够实时显示天气信息、环境数据和时间。
</p>
<div class="row">
<div class="col">
<h3 class="mt-3">硬件规格</h3>
<div class="list">
<div class="list-item">
<strong>硬件平台:</strong> ESP8266
<span class="badge badge-info">WiFi</span>
</div>
<div class="list-item">
<strong>显示屏:</strong> LCD 240x240
<span class="badge badge-info">彩色</span>
</div>
<div class="list-item">
<strong>环境参数:</strong> 温度、湿度、PM2.5、气压、AQI
</div>
</div>
</div>
<div class="col">
<h3 class="mt-3">软件信息</h3>
<div class="list">
<div class="list-item">
<strong>固件:</strong>
<a href="https://iot.foresh.com/git/kicer/ws2" target="_blank"> ws2-v1.3.0 </a>
<span class="badge badge-success">开源</span>
</div>
<div class="list-item">
<strong>协议:</strong> HTTP REST API
</div>
<div class="list-item">
<strong>更新频率:</strong> 每小时
</div>
</div>
</div>
</div>
<h3 class="mt-3">开放源码</h3>
<a href="https://github.com/kicer/ws2" target="_blank" class="btn btn-outline"> Kicer@Github: ws2 </a>
<a href="https://iot.foresh.com/git/kicer/ws2" target="_blank" class="btn btn-outline"> Kicer@foresh: ws2国内访问</a>
<h3 class="mt-3">软件许可</h3>
<p>本项目采用MIT许可证开源欢迎自由使用和修改。</p>
</div>
</div>
<script src="./js/micro.js"></script>
<script>
// 全局变量
let currentConfig = {};
// 页面加载完成后执行
document.addEventListener("DOMContentLoaded", function () {
initTabSwitching();
initEventHandlers();
refreshStatus();
loadConfig();
updateDisplaySettings();
});
// 初始化选项卡切换
function initTabSwitching() {
mw.$$(".nav-tab").forEach(tab => {
mw.on(tab, "click", function () {
const tabName = mw.attr(this, "data-tab");
showTab(tabName);
});
});
}
// 初始化事件处理器
function initEventHandlers() {
// 退出按钮
mw.on(mw.$("#logout-btn"), "click", function () {
alert("退出功能暂未实现");
});
// 亮度滑块
mw.on(mw.$("#brightness-slider"), "input", function () {
mw.text(mw.$("#brightness-value"), mw.val(this));
});
// 刷新状态按钮
mw.on(mw.$("#refresh-status-btn"), "click", refreshStatus);
// 应用显示设置按钮
mw.on(
mw.$("#apply-display-btn"),
"click",
applyDisplaySettings,
);
// 保存配置按钮
mw.on(mw.$("#save-config-btn"), "click", saveConfig);
}
// 更新LCD状态徽章
function updateLcdBadges(isReady) {
const badge1 = mw.$("#lcd-ready-badge");
const badge2 = mw.$("#lcd-ready-badge-display");
if (isReady) {
mw.text(badge1, "正常");
mw.text(badge2, "正常");
mw.replaceClass(badge1, "badge-warning", "badge-success");
mw.replaceClass(badge2, "badge-warning", "badge-success");
} else {
mw.text(badge1, "未就绪");
mw.text(badge2, "未就绪");
mw.replaceClass(badge1, "badge-success", "badge-warning");
mw.replaceClass(badge2, "badge-success", "badge-warning");
}
}
// 切换选项卡
function showTab(tabName) {
// 隐藏所有选项卡内容
mw.$$(".tab-content").forEach((tab) =>
mw.removeClass(tab, "active"),
);
// 移除所有选项卡的激活类
mw.$$(".nav-tab").forEach((tab) =>
mw.removeClass(tab, "active"),
);
// 显示选中的选项卡
mw.addClass(mw.$(`#${tabName}-tab`), "active");
// 激活选中的导航
mw.addClass(mw.$(`[data-tab="${tabName}"]`), "active");
// 根据选项卡刷新数据
if (tabName === "status") {
refreshStatus();
} else if (tabName === "config") {
loadConfig();
} else if (tabName === "display") {
updateDisplaySettings();
}
}
// 刷新状态
async function refreshStatus() {
try {
// 获取系统状态
const statusResponse = await mw.ajax.get("/status");
const statusData = JSON.parse(statusResponse);
// 使用数据绑定更新系统状态
mw.bind(statusData);
// 更新内存仪表盘
updateMemoryGauge(statusData.memory);
showMessage("状态已更新", "success");
} catch (error) {
showMessage("获取状态失败: " + error.message, "error");
}
}
// 更新内存仪表盘
function updateMemoryGauge(memoryStr) {
// 从内存字符串中提取数值,例如 "100 KB" -> 100
const match = memoryStr.match(/(\d+)/);
if (match) {
// 这里简化处理假设最大可用内存为50KB
const maxMemory = 50;
const memoryValue = parseInt(match[1]);
const percentage = Math.min(
100,
Math.round((memoryValue / maxMemory) * 100),
);
// 使用micro.js的图表功能创建仪表盘
mw.chart.createGauge(
mw.$("#memory-gauge"),
percentage,
100,
{
label: "内存使用率",
color:
percentage > 80
? "#e74c3c"
: percentage > 50
? "#f39c12"
: "#27ae60",
},
);
}
}
// 更新显示设置
async function updateDisplaySettings() {
try {
const response = await mw.ajax.get("/lcd");
const data = JSON.parse(response);
// 更新LCD状态
const lcdBindings = {
"lcd-ready": data.ready ? "就绪" : "未就绪",
"lcd-brightness": data.brightness,
"lcd-ui-type": data.ui_type,
"lcd-data": JSON.stringify(data.data),
};
mw.bind(lcdBindings);
// 更新亮度滑块
mw.val(mw.$("#brightness-slider"), data.brightness);
mw.text(mw.$("#brightness-value"), data.brightness);
// 更新UI类型选择框
mw.val(mw.$("#ui-type-select"), data.ui_type);
} catch (error) {
showMessage("获取LCD状态失败: " + error.message, "error");
}
}
// 应用显示设置
async function applyDisplaySettings() {
try {
const brightness = mw.val(mw.$("#brightness-slider"));
const uiType = mw.val(mw.$("#ui-type-select"));
const response = await mw.ajax.post("/lcd/set", {
brightness: brightness,
ui_type: uiType,
});
const data = JSON.parse(response);
if (data.status === "success") {
showMessage("显示设置已应用", "success");
refreshStatus();
} else {
showMessage(
"设置失败: " + (data.message || "未知错误"),
"error",
);
}
} catch (error) {
showMessage("请求失败: " + error.message, "error");
}
}
// 加载配置
async function loadConfig() {
try {
const response = await mw.ajax.get("/config");
const data = JSON.parse(response);
currentConfig = data;
// 更新配置表单
if (data.city) {
mw.val(mw.$("#city-input"), data.city);
}
if (data.screen_timeout) {
mw.val(
mw.$("#screen-timeout-input"),
data.screen_timeout,
);
}
// 更新配置表
updateConfigTable(data);
} catch (error) {
showMessage("获取配置失败: " + error.message, "error");
}
}
// 更新配置表格
function updateConfigTable(data) {
const configTable = mw.$("#current-config");
// 清除现有行(保留标题行)
while (configTable.rows.length > 1) {
configTable.deleteRow(1);
}
// 添加新行
for (const key in data) {
const row = configTable.insertRow();
const cell1 = row.insertCell(0);
const cell2 = row.insertCell(1);
mw.text(cell1, key);
mw.text(cell2, data[key]);
}
}
// 保存配置
async function saveConfig() {
try {
const city = mw.val(mw.$("#city-input"));
const screenTimeout = mw.val(mw.$("#screen-timeout-input"));
if (!city) {
showMessage("城市名称不能为空", "error");
return;
}
const configData = {
city: city,
};
// 只有当输入了熄屏时间时才添加到配置中
if (screenTimeout !== "") {
configData.screen_timeout = screenTimeout;
}
const response = await mw.ajax.post(
"/config/set",
configData,
);
const data = JSON.parse(response);
if (data.status === "success") {
showMessage("配置已保存", "success");
loadConfig();
} else {
showMessage(
"保存失败: " + (data.message || "未知错误"),
"error",
);
}
} catch (error) {
showMessage("请求失败: " + error.message, "error");
}
}
// 显示消息
function showMessage(text, type) {
const messageEl = mw.$("#message");
mw.text(messageEl, text);
mw.addClass(messageEl, "alert-" + type);
mw.show(messageEl);
setTimeout(() => {
mw.hide(messageEl);
}, 3000);
}
</script>
</body>
</html>