Compare commits

...

10 Commits

Author SHA1 Message Date
21c7f3f990 修复配网时城市输入乱码的问题 2026-02-02 08:14:35 +08:00
e0177bc005 zip index.html 2026-02-02 00:22:22 +08:00
d0316a765a fix bugs 2026-02-02 00:09:10 +08:00
1291ebfedc backup codes 2026-02-02 00:03:41 +08:00
242d4cadf0 fix standby_time 2026-02-01 23:30:45 +08:00
566efbcf1f web配置页面基本框架完成 2026-02-01 23:00:05 +08:00
a84ff1ea49 fix issues 2026-02-01 22:16:03 +08:00
b068b9cf49 基础版本ui完成 2026-02-01 18:52:36 +08:00
9415bde6b9 add web auth 2026-02-01 15:56:24 +08:00
497c278623 fix web 2026-02-01 09:21:50 +08:00
8 changed files with 947 additions and 500 deletions

View File

@@ -1,23 +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
# 全局变量存储最新的天气数据
latest_weather = None
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 = {}
@@ -34,6 +33,163 @@ def parse_url_params(url):
return params return params
async def post_parse(request):
if request.method != "POST":
raise Exception("invalid request")
content_length = int(request.headers["Content-Length"])
return (await request.read(content_length)).decode()
async def json_response(request, data):
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(data)
CREDENTIALS = ("admin", config.get("web_password", "admin"))
def authenticate(credentials):
async def fail(request):
await request.write("HTTP/1.1 401 Unauthorized\r\n")
await request.write('WWW-Authenticate: Basic realm="Restricted"\r\n\r\n')
await request.write("<h1>Unauthorized</h1>")
def decorator(func):
async def wrapper(request):
from ubinascii import a2b_base64 as base64_decode
header = request.headers.get("Authorization", None)
if header is None:
return await fail(request)
# Authorization: Basic XXX
kind, authorization = header.strip().split(" ", 1)
if kind != "Basic":
return await fail(request)
authorization = (
base64_decode(authorization.strip()).decode("ascii").split(":")
)
if list(credentials) != list(authorization):
return await fail(request)
return await func(request)
return wrapper
return decorator
# /status: 获取系统状态
@authenticate(credentials=CREDENTIALS)
async def sys_status(request):
await json_response(
request,
json.dumps(
{
"time": "{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format(
*time.localtime()
),
"uptime": time.ticks_ms(),
"mem_free": gc.mem_free(),
"mem_alloc": gc.mem_alloc(),
"uuid": uuid(),
"platform": str(sys.platform),
"version": str(sys.version),
}
),
)
# /lcd: 获取LCD状态
@authenticate(credentials=CREDENTIALS)
async def lcd_status(request):
# 返回LCD状态
await json_response(
request,
json.dumps(
{
"ready": display.is_ready(),
"brightness": display.brightness(),
"ui_type": display.ui_type,
"data": display.ui_data,
}
),
)
# /config: 获取当前配置
@authenticate(credentials=CREDENTIALS)
async def config_get(request):
# 返回所有配置项
await json_response(request, json.dumps(config.config_data))
# /lcd/set: 设置LCD状态
@authenticate(credentials=CREDENTIALS)
async def lcd_set(request):
ack = {"status": "success"}
try:
post_data = await post_parse(request)
for k, v in json.loads(post_data).items():
if k == "brightness":
display.brightness(int(v))
elif k == "ui_type":
display.ui_type = v
config.set(k, v)
config.write()
except Exception as e:
ack["status"] = "error"
ack["message"] = str(e)
finally:
await json_response(request, json.dumps(ack))
# /config/set: 更新配置
# curl -H "Content-Type: application/json" -X POST -d '{"city":"xxx","who":"ami"}' 'http://<url>/config/set'
@authenticate(credentials=CREDENTIALS)
async def config_update(request):
ack = {"status": "success"}
try:
post_data = await post_parse(request)
for k, v in json.loads(post_data).items():
config.set(k, v)
config.write()
except Exception as e:
ack["status"] = "error"
ack["message"] = str(e)
finally:
await json_response(request, json.dumps(ack))
# /exec: 执行命令并返回
# {"cmd":"import network;R=network.WLAN().config(\"mac\").hex()", "token":"xxx"}
@authenticate(credentials=CREDENTIALS)
async def eval_cmd(request):
ack = {"status": "success"}
try:
post_data = await post_parse(request)
_json = json.loads(post_data)
cmd = _json.get("cmd")
token = _json.get("token")
if cmd and token == uuid():
_NS = {}
exec(cmd, _NS)
ack["result"] = str(_NS.get("R"))
else:
raise Exception("invalid token")
except Exception as e:
ack["status"] = "error"
ack["message"] = str(e)
finally:
await json_response(request, json.dumps(ack))
# ntp时钟同步 # ntp时钟同步
def sync_ntp_time(): def sync_ntp_time():
import ntptime import ntptime
@@ -50,32 +206,19 @@ def sync_ntp_time():
# 简化的天气数据获取函数 # 简化的天气数据获取函数
async def get_weather_data(city=None, force=False): async def fetch_weather_data(city=None):
"""获取天气数据返回JSON格式数据 # 获取天气数据返回JSON格式数据
Args:
city: 城市名称如果为None则从配置文件获取
force: 是否强制刷新True则忽略缓存重新获取
"""
global latest_weather
# 检查是否需要强制更新或者缓存为空
if not force and latest_weather is not None:
# 使用缓存数据
print("使用缓存的天气数据")
return latest_weather
try: try:
import aiohttp import aiohttp
# 从配置文件获取城市,如果没有提供则使用配置中的值 # 从配置文件获取城市,如果没有提供则使用配置中的值
if not city: if not city:
city = config.get("city") or "北京" city = config.get("cityid") or "北京"
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:
@@ -99,21 +242,20 @@ async def get_weather_data(city=None, force=False):
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,
latest_weather = display.ui_data aqi,
print("天气数据获取成功") lunar,
ip,
return latest_weather envdat={"t": t, "rh": rh, "co2": co2, "pm": pm, "ap": ap},
)
else: else:
print(f"获取天气数据失败,状态码: {response.status}") print(f"获取天气数据失败,状态码: {response.status}")
return None
except Exception as e: except Exception as e:
print(f"获取天气数据出错: {e}") print(f"获取天气数据出错: {e}")
return None
# 定时调度任务(时间不敏感,但考虑错开以节约内存) # 定时调度任务(时间不敏感,但考虑错开以节约内存)
@@ -138,7 +280,7 @@ async def sysinfo_update_task():
# 更新天气数据 # 更新天气数据
gc.collect() gc.collect()
task_id = "weather" task_id = "weather"
await get_weather_data(force=True) await fetch_weather_data()
last_weather = current_ticks last_weather = current_ticks
weather_ts = int(config.get("weather_ts", 600)) # 10min weather_ts = int(config.get("weather_ts", 600)) # 10min
elif ntp_diff >= ntptime_ts * 1000: elif ntp_diff >= ntptime_ts * 1000:
@@ -178,7 +320,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)
@@ -204,16 +346,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():
# 初始化液晶屏 # 初始化液晶屏
@@ -224,14 +369,15 @@ 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()
gc.collect() gc.collect()
display.load_ui() display.load_ui(config.get('ui_type', 'default'))
# init web server # init web server
from rom.nanoweb import Nanoweb from rom.nanoweb import Nanoweb
@@ -239,143 +385,17 @@ 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"
# /ping: pong # Declare route from a dict
@naw.route("/ping") naw.routes = {
async def ping(request): '/lcd': lcd_status,
await request.write("HTTP/1.1 200 OK\r\n") '/config': config_get,
await request.write("Content-Type: text\r\n\r\n") '/status': sys_status,
await request.write("pong") '/exec': eval_cmd,
'/lcd/set': lcd_set,
# /status '/config/set': config_update,
@naw.route("/status")
async def ping(request):
await request.write("HTTP/1.1 200 OK\r\n")
await request.write("Content-Type: application/json\r\n\r\n")
await request.write(
json.dumps(
{
"time": "{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format(
*time.localtime()
),
"uptime": str(f"{time.ticks_ms() // 1000} sec"),
"memory": str(f"{gc.mem_free() // 1000} KB"),
"uuid": uuid,
"platform": str(sys.platform),
"version": str(sys.version),
} }
)
)
# /weather: 返回天气数据
@naw.route("/weather*")
async def weather_status(request):
await request.write("HTTP/1.1 200 OK\r\n")
await request.write("Content-Type: application/json\r\n\r\n")
# 解析URL参数
params = parse_url_params(request.url)
# 获取天气数据
weather = get_weather_data(
city=params.get("city"), force=params.get("force", False)
)
if weather:
await request.write(json.dumps(weather))
else:
await request.write(json.dumps({"error": "Failed to get weather data"}))
# /lcd: 获取LCD状态
@naw.route("/lcd")
async def lcd_status(request):
await request.write("HTTP/1.1 200 OK\r\n")
await request.write("Content-Type: application/json\r\n\r\n")
# 返回LCD状态
lcd_status = {
"ready": display.is_ready(),
"brightness": display.brightness(),
"ui_type": display.ui_type,
}
await request.write(json.dumps(lcd_status))
# /lcd/set: 设置LCD状态
@naw.route("/lcd/set")
async def lcd_set(request):
ack = {"status": "success"}
try:
if request.method != "POST":
raise Exception("invalid request")
content_length = int(request.headers["Content-Length"])
post_data = (await request.read(content_length)).decode()
for k, v in json.loads(post_data).items():
if k == "brightness":
display.brightness(int(v))
except Exception as e:
ack["status"] = "error"
ack["message"] = str(e)
finally:
await request.write(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n"
)
await request.write(json.dumps(ack))
# /exec: 执行命令并返回
# {"cmd":"import network;R=network.WLAN().config(\"mac\").hex()", "token":"xxx"}
@naw.route("/exec")
async def eval_cmd(request):
ack = {"status": "success"}
try:
if request.method != "POST":
raise Exception("invalid request")
content_length = int(request.headers["Content-Length"])
post_data = (await request.read(content_length)).decode()
cmd = json.loads(post_data).get("cmd")
token = json.loads(post_data).get("token")
if cmd and token == uuid:
_NS = {}
exec(cmd, _NS)
ack["result"] = str(_NS.get("R"))
except Exception as e:
ack["status"] = "error"
ack["message"] = str(e)
finally:
await request.write(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n"
)
await request.write(json.dumps(ack))
# /config: 获取当前配置
@naw.route("/config")
async def config_get(request):
await request.write("HTTP/1.1 200 OK\r\n")
await request.write("Content-Type: application/json\r\n\r\n")
# 返回所有配置项
await request.write(json.dumps(config.config_data))
# /config/set: 更新配置
# curl -H "Content-Type: application/json" -X POST -d '{"city":"xxx","who":"ami"}' 'http://<url>/config/set'
@naw.route("/config/set")
async def config_update(request):
ack = {"status": "success"}
try:
if request.method != "POST":
raise Exception("invalid request")
content_length = int(request.headers["Content-Length"])
post_data = (await request.read(content_length)).decode()
for k, v in json.loads(post_data).items():
config.set(k, v)
config.write()
except Exception as e:
ack["status"] = "error"
ack["message"] = str(e)
finally:
await request.write(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n"
)
await request.write(json.dumps(ack))
# create task # create task
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()

View File

@@ -119,11 +119,13 @@ class HTTPServer(BaseServer):
ssid = unquote(params.get(b"ssid", None)) ssid = unquote(params.get(b"ssid", None))
password = unquote(params.get(b"password", "")) password = unquote(params.get(b"password", ""))
city = unquote(params.get(b"city", None)) city = unquote(params.get(b"city", None))
cityid = params.get(b"city", None)
# 使用全局Config实例保存配置 # 使用全局Config实例保存配置
config.set("ssid", ssid) config.set("ssid", ssid)
config.set("password", password) config.set("password", password)
config.set("city", city) config.set("city", city)
config.set("cityid", cityid)
if config.write(): if config.write():
print("Configuration saved successfully") print("Configuration saved successfully")
else: else:

View File

@@ -68,7 +68,7 @@ class Display:
self.tft.init() self.tft.init()
self.tft.fill(0) self.tft.fill(0)
self.show_jpg(self.bootimg, 80, 80) self.show_jpg(self.bootimg, 80, 80)
self.message("WS2 v1.2.1 (20260131)") self.message("WS2 v1.3.5 (20260202)")
_print_mem() _print_mem()
return True return True
@@ -243,8 +243,8 @@ class Display:
_print_mem() _print_mem()
# 初始化ui固定元素 # 初始化ui固定元素
def load_ui(self): def load_ui(self, ui_type='default'):
if self.ui_type == 'default': if ui_type == 'default':
# 默认黑色背景 # 默认黑色背景
self.tft.fill(0) self.tft.fill(0)
# 固定的环境数据图标 # 固定的环境数据图标
@@ -252,9 +252,12 @@ class Display:
self.show_jpg("/rom/images/rh.jpg",85,177) self.show_jpg("/rom/images/rh.jpg",85,177)
self.show_jpg("/rom/images/pm.jpg",11,209) self.show_jpg("/rom/images/pm.jpg",11,209)
self.show_jpg("/rom/images/ap.jpg",85,209) self.show_jpg("/rom/images/ap.jpg",85,209)
else:
self.tft.fill(0)
# 更新其他默认数据 # 更新其他默认数据
self.update_ui() self.update_ui()
self.ui_type = ui_type
# 全局液晶屏实例 # 全局液晶屏实例
display = Display() display = Display()

View File

@@ -36,7 +36,7 @@ async def error(request, code, reason):
await request.write(str(reason)) await request.write(str(reason))
async def send_file(request, filename, segment=64, binary=True): async def send_file(request, filename, segment=512, binary=True):
try: try:
with open(filename, "rb" if binary else "r") as f: with open(filename, "rb" if binary else "r") as f:
while True: while True:
@@ -91,7 +91,8 @@ class Nanoweb:
handler = (request.url, handler) handler = (request.url, handler)
if isinstance(handler, str): if isinstance(handler, str):
await write(request, "HTTP/1.1 200 OK\r\n\r\n") await write(request, "HTTP/1.1 200 OK\r\n")
await write(request, "Cache-Control: max-age=3600\r\n\r\n")
await send_file(request, handler) await send_file(request, handler)
elif isinstance(handler, tuple): elif isinstance(handler, tuple):
await write(request, "HTTP/1.1 200 OK\r\n\r\n") await write(request, "HTTP/1.1 200 OK\r\n\r\n")

1
src/rom/www/css/micro.min.css vendored Normal file
View File

@@ -0,0 +1 @@
.card,body{padding:20px}.card,h1{margin-bottom:20px}.form-control,.table{width:100%;font-size:14px}.btn,.form-control,.table{font-size:14px}.list,.progress{overflow:hidden}*{box-sizing:border-box;margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.5;background:#f8f9fa;color:#333;max-width:800px;margin:0 auto}.card{background:#fff;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,.05);border:1px solid #e9ecef}h1{font-size:24px;color:#2c3e50}h2{font-size:20px;margin:15px 0;color:#34495e}h3{font-size:16px;margin:10px 0;color:#7f8c8d}.btn{display:inline-block;padding:8px 16px;background:#3498db;color:#fff;border:none;border-radius:4px;cursor:pointer;text-decoration:none;transition:background .2s;margin:2px}.btn:hover{background:#2980b9}.btn:active{transform:translateY(1px)}.btn-success{background:#27ae60}.btn-success:hover{background:#219653}.btn-danger{background:#e74c3c}.btn-danger:hover{background:#c0392b}.btn-warning{background:#f39c12}.btn-warning:hover{background:#d68910}.btn-outline{background:0 0;border:1px solid #3498db;color:#3498db}.badge-info,.btn-outline:hover{background:#3498db;color:#fff}.list-item:hover,.table th,.table tr:hover,.table-striped tr:nth-child(2n){background:#f8f9fa}.form-group{margin-bottom:15px}.form-label{display:block;margin-bottom:5px;font-weight:500;color:#555}.form-control{padding:10px;border:1px solid #ddd;border-radius:4px;transition:border .2s}.alert,.list-item{padding:12px 15px}.form-control:focus{outline:0;border-color:#3498db;box-shadow:0 0 0 2px rgba(52,152,219,.2)}.form-control:disabled{background:#f8f9fa;cursor:not-allowed}.checkbox,.radio{display:flex;align-items:center;margin:5px 0;cursor:pointer}.alert,.table{margin:10px 0}.checkbox input,.radio input{margin-right:8px}.table{border-collapse:collapse}.table td,.table th{padding:10px;text-align:left;border-bottom:1px solid #eee}.table th{font-weight:600;color:#555}.list{list-style:none;border:1px solid #eee;border-radius:4px}.list-item{border-bottom:1px solid #eee;background:#fff}.list-item:last-child{border-bottom:none}.alert{border-radius:4px;border-left:4px solid}.alert-success{background:#d4edda;border-left-color:#27ae60;color:#155724}.alert-warning{background:#fff3cd;border-left-color:#f39c12;color:#856404}.alert-error{background:#f8d7da;border-left-color:#e74c3c;color:#721c24}.alert-info{background:#d1ecf1;border-left-color:#3498db;color:#0c5460}.badge{display:inline-block;padding:3px 8px;font-size:12px;border-radius:20px;background:#e9ecef;color:#495057;margin:0 2px}.badge-success{background:#27ae60;color:#fff}.badge-warning{background:#f39c12;color:#fff}.badge-danger{background:#e74c3c;color:#fff}.progress{height:20px;background:#f0f0f0;border-radius:10px;margin:10px 0}.progress-bar{height:100%;background:#3498db;transition:width .3s;border-radius:10px}.row{display:flex;flex-wrap:wrap;margin:0 -10px}.col{flex:1;padding:0 10px;min-width:200px}.text-center{text-align:center}.text-right{text-align:right}.text-muted{color:#6c757d}.mt-1{margin-top:5px}.mt-2{margin-top:10px}.mt-3{margin-top:20px}.mb-1{margin-bottom:5px}.mb-2{margin-bottom:10px}.mb-3{margin-bottom:20px}.p-1{padding:5px}.p-2{padding:10px}.p-3{padding:20px}.hidden{display:none!important}@media (max-width:768px){body{padding:10px}.card{padding:15px}.row{flex-direction:column}.col{width:100%}}

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

@@ -0,0 +1,382 @@
<!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.min.css" /><style>
.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></div><div
class="alert"
style="
position: fixed;
top: 25px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
display: none;
"
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="advanced">高级设置</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="mem_free">-</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></div><div id="display-tab" class="tab-content"><h3>LCD显示设置</h3><div class="form-group"><div
style="
display: flex;
justify-content: space-between;
align-items: center;
"
><label class="form-label mb-0">显示状态</label
><span
id="lcd-ready-badge"
class="badge badge-warning"
></span></div></div><div class="form-group"><label class="form-label">屏幕亮度</label><div style="display: flex; align-items: center"><input
type="range"
id="brightness-slider"
min="0"
max="100"
class="form-control"
style="margin-right: 10px; padding: 10px 0"
/><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><h3 class="mt-3">LCD数据内容</h3><table class="table" id="lcd-data-table"><tr><th>属性</th><th></th></tr></table></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"
download
>查看城市ID列表</a
>
</small></div><div class="form-group"><label class="form-label">自动熄屏时间</label
><input
type="time"
id="standby-time-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="advanced-tab" class="tab-content"><h3>快捷操作</h3><div class="form-group"><button class="btn btn-outline" data-cmd="get_mac">
MAC地址
</button><button class="btn btn-outline" data-cmd="reboot">
重启设备
</button><button class="btn btn-outline" data-cmd="reset_config">
清空配置
</button></div><h3 class="mt-3">命令执行</h3><div class="form-group"><label class="form-label">命令</label><textarea
id="command-input"
class="form-control"
rows="4"
placeholder="输入要执行的命令..."
></textarea></div><div class="form-group"><label class="form-label">Token</label><input
type="text"
id="token-input"
class="form-control"
placeholder="输入认证token可选"
/></div><div class="form-group"><button id="send-cmd-btn" class="btn btn-success">
发送命令
</button></div><div class="form-group"><label class="form-label">应答</label><textarea
id="response-output"
class="form-control"
rows="8"
readonly
placeholder="命令应答将显示在这里..."
></textarea></div></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-firmware-v1.3.5-4M.bin </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.min.js"></script><script>
// 全局变量
let currentConfig = {};// 页面加载完成后执行
document.addEventListener("DOMContentLoaded", function () {
initTabSwitching();
initEventHandlers();
refreshStatus();
});// 初始化选项卡切换
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.$("#brightness-slider"), "input", function () {
mw.text(mw.$("#brightness-value"), mw.val(this));
});// 应用显示设置按钮
mw.on(
mw.$("#apply-display-btn"),
"click",
applyDisplaySettings,
);// 保存配置按钮
mw.on(mw.$("#save-config-btn"), "click", saveConfig);// 初始化高级设置选项卡的事件处理器
initAdvancedTab();
}// 初始化高级设置选项卡
function initAdvancedTab() {
// 快捷操作按钮
mw.$$("#advanced-tab .btn-outline").forEach((btn) => {
mw.on(btn, "click", function () {
const cmd = mw.attr(this, "data-cmd");
if (cmd) {
const commands = {
get_mac:
'import network;R=network.WLAN().config("mac").hex()',
reboot: "import machine;machine.reset()",
reset_config:
'import os;os.remove("/config.json")',
};
mw.val(mw.$("#command-input"), commands[cmd] || "");
}
});
});// 发送命令按钮
mw.on(mw.$("#send-cmd-btn"), "click", async function () {
const command = mw.val(mw.$("#command-input"));
const token = mw.val(mw.$("#token-input"));if (!command.trim()) {
showMessage("请输入命令", "error");
return;
}try {
mw.val(mw.$("#response-output"), "执行中...");
const response = await mw.ajax.post("/exec", {
cmd: command,
token: token,
});
mw.val(mw.$("#response-output"), response);
showMessage("命令执行成功", "success");
} catch (error) {
mw.val(
mw.$("#response-output"),
"错误: " + error.message,
);
showMessage("命令执行失败", "error");
}
});
}// 切换选项卡
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();
}
}// 更新LCD状态徽章
function updateLcdBadges(isReady) {
const badge = mw.$("#lcd-ready-badge");
if (isReady) {
mw.text(badge, "正常");
mw.replaceClass(badge, "badge-warning", "badge-success");
} else {
mw.text(badge, "未就绪");
mw.replaceClass(badge, "badge-success", "badge-warning");
}
}// 刷新状态
async function refreshStatus() {
try {
// 获取系统状态
const response = await mw.ajax.get("/status");
const data = JSON.parse(response);// 使用数据绑定更新系统状态
mw.bind(data);// 更新内存仪表盘
updateMemoryGauge(data.mem_free, data.mem_alloc);showMessage("状态已更新", "success");
} catch (error) {
showMessage("获取状态失败: " + error.message, "error");
}
}// 更新内存仪表盘
function updateMemoryGauge(free, alloc) {
const maxMemory = parseInt(free) + parseInt(alloc);
const memoryValue = parseInt(alloc);
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);// 更新亮度滑块和显示值
mw.val(mw.$("#brightness-slider"), data.brightness);
mw.text(mw.$("#brightness-value"), data.brightness);// 更新UI类型选择框
mw.val(mw.$("#ui-type-select"), data.ui_type);// 更新LCD状态徽章
updateLcdBadges(data.ready);// 更新LCD数据表格
updateLcdDataTable(data.data);
} 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");
// 刷新显示设置
await updateDisplaySettings();
} 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.standby_time) {
mw.val(mw.$("#standby-time-input"), data.standby_time);
}// 更新配置表
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]);
}
}// 更新LCD数据表格
function updateLcdDataTable(data) {
const dataTable = mw.$("#lcd-data-table");// 清除现有行(保留标题行)
while (dataTable.rows.length > 1) {
dataTable.deleteRow(1);
}// 添加数据行
for (const key in data) {
const row = dataTable.insertRow();
const cell1 = row.insertCell(0);
const cell2 = row.insertCell(1);mw.text(cell1, key);
let value = data[key];
// 如果值是对象则转换为JSON字符串
if (typeof value === "object") {
value = JSON.stringify(value);
}
mw.text(cell2, value);
}
}// 保存配置
async function saveConfig() {
try {
const city = mw.val(mw.$("#city-input"));
const standbyTime = mw.val(mw.$("#standby-time-input"));if (!city) {
showMessage("城市名称不能为空", "error");
return;
}const configData = {
city: city,
cityid: encodeURIComponent(city),
};// 只有当输入了熄屏时间时才添加到配置中
if (standbyTime !== "") {
configData.standby_time = standbyTime;
}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);
mw.removeClass(messageEl, "alert-" + type);
}, 3000);
}
</script></body></html>

View File

@@ -1,9 +1,4 @@
<html> <html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>WiFi认证</title><style>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WiFi认证</title>
<style>
body { body {
font-family: sans-serif; font-family: sans-serif;
background: #3498db; background: #3498db;
@@ -190,55 +185,32 @@
margin: 15px auto; margin: 15px auto;
animation: spin 1.5s linear infinite; animation: spin 1.5s linear infinite;
} }
</style> </style></head><body><form action="/login" method="get" class="box"><h1>WiFi 配置</h1><button
</head>
<body>
<form action="/login" method="get" class="box">
<h1>WiFi 配置</h1>
<button
type="button" type="button"
class="refresh-btn" class="refresh-btn"
onclick="refreshList()" onclick="refreshList()"
title="刷新WiFi列表" title="刷新WiFi列表"
> >
</button> </button><div id="wifiList" class="wifi-list"></div><input
<div id="wifiList" class="wifi-list"></div>
<input
type="text" type="text"
id="ssid" id="ssid"
placeholder="WiFi名称" placeholder="WiFi名称"
name="ssid" name="ssid"
required required
/><br /> /><br /><input
<input
type="password" type="password"
id="pwd" id="pwd"
placeholder="WiFi密码" placeholder="WiFi密码"
name="password" name="password"
/><br /> /><br /><input
<input
type="text" type="text"
placeholder="城市名称" placeholder="城市名称"
name="city" name="city"
value="" value=""
/><br /> /><br /><button type="submit" class="btn">保存配置</button></form><div class="overlay" id="connectingOverlay"><div class="overlay-content"><h2>正在连接到WiFi</h2><div class="spinner-small"></div><p>设备正在尝试连接,请稍候...</p></div></div><script>
<button type="submit" class="btn">保存配置</button>
</form>
<!-- 遮罩层 -->
<div class="overlay" id="connectingOverlay">
<div class="overlay-content">
<h2>正在连接到WiFi</h2>
<div class="spinner-small"></div>
<p>设备正在尝试连接,请稍候...</p>
</div>
</div>
<script>
window.onload = function () { window.onload = function () {
fetchWifiList(); fetchWifiList();// 添加表单提交事件监听
// 添加表单提交事件监听
document document
.querySelector(".box") .querySelector(".box")
.addEventListener("submit", function (e) { .addEventListener("submit", function (e) {
@@ -298,7 +270,7 @@
item.appendChild(signalContainer); item.appendChild(signalContainer);
item.onclick = function () { item.onclick = function () {
document.getElementById("ssid").value = network.ssid; document.getElementById("ssid").value = network.ssid;
document.getElementById('pwd').focus(); document.getElementById("pwd").focus();
}; };
listContainer.appendChild(item); listContainer.appendChild(item);
} }
@@ -313,6 +285,4 @@
btn.disabled = false; btn.disabled = false;
}, 1000); }, 1000);
} }
</script> </script></body></html>
</body>
</html>

68
src/rom/www/js/micro.min.js vendored Normal file
View File

@@ -0,0 +1,68 @@
const MicroWeb={$:t=>document.querySelector(t),$$:t=>document.querySelectorAll(t),show:t=>t.style.display="block",hide:t=>t.style.display="none",toggle:t=>t.style.display="none"===t.style.display?"block":"none",text(t,e){if(void 0===e)return t.textContent;t.textContent=e},html(t,e){if(void 0===e)return t.innerHTML;t.innerHTML=e},val(t,e){if(void 0===e)return t.value;t.value=e},attr(t,e,r){if(void 0===r)return t.getAttribute(e);t.setAttribute(e,r)},addClass:(t,e)=>t.classList.add(e),removeClass:(t,e)=>t.classList.remove(e),hasClass:(t,e)=>t.classList.contains(e),replaceClass(t,e,r){t.classList.remove(e),t.classList.add(r)},on(t,e,r,i){"function"==typeof r?(i=r,t.addEventListener(e,i)):t.addEventListener(e,t=>{t.target.matches(r)&&i.call(t.target,t)})},ajax:{async get(t,e={}){try{let r=e.headers||{},i=await fetch(t,{headers:r});return await i.text()}catch(o){return console.error("GET failed:",o),null}},async post(t,e,r={}){try{let i=Object.assign({"Content-Type":"application/json"},r.headers||{}),o=await fetch(t,{method:"POST",headers:i,body:JSON.stringify(e)});return await o.text()}catch(a){return console.error("POST failed:",a),null}},async postForm(t,e){let r=new FormData(e);try{let i=await fetch(t,{method:"POST",body:r});return await i.text()}catch(o){return console.error("Form POST failed:",o),null}}},bind(t,e=""){Object.keys(t).forEach(r=>{let i=document.querySelectorAll(`[data-bind="${e}${r}"]`);i.forEach(e=>{e.textContent=t[r]})})},render:(t,e)=>t.replace(/\{\{(\w+)\}\}/g,(t,r)=>void 0!==e[r]?e[r]:""),chart:{createProgress(t,e,r=100,i={}){let o=i.width||"100%",a=i.height||"20px",l=i.color||"#007bff",n=i.bgColor||"#f0f0f0",s=e/r*100,d=`
<div style="
width: ${o};
height: ${a};
background: ${n};
border-radius: 10px;
overflow: hidden;
margin: 5px 0;">
<div style="
width: ${s}%;
height: 100%;
background: ${l};
transition: width 0.3s;
border-radius: 10px;">
</div>
</div>
<small>${e} / ${r} (${s.toFixed(1)}%)</small>
`;t.innerHTML=d},createBarChart(t,e,r={}){let i=Math.max(...e.values);r.width;let o=r.height||200,a=r.colors||["#007bff","#28a745","#ffc107","#dc3545",],l=`<div style="display: flex; align-items: flex-end; height: ${o}px; gap: 10px;">`;e.values.forEach((t,r)=>{let n=e.labels?e.labels[r]:`Item ${r+1}`,s=a[r%a.length];l+=`
<div style="text-align: center;">
<div style="
width: 40px;
height: ${t/i*(o-40)}px;
background: ${s};
border-radius: 5px 5px 0 0;
margin: 0 auto;">
</div>
<div style="font-size: 12px; margin-top: 5px;">
${n}<br>
<strong>${t}</strong>
</div>
</div>
`}),l+="</div>",t.innerHTML=l},createGauge(t,e,r=100,i={}){let o=i.size||150,a=i.color||"#007bff",l=`
<div style="position: relative; width: ${o}px; height: ${o}px;
overflow: hidden; margin: 0 auto;">
<div style="
position: absolute;
width: ${o}px;
height: ${o}px;
border: ${o/10}px solid #f0f0f0;
border-radius: 50%;
border-bottom-color: transparent;
border-left-color: transparent;">
</div>
<div style="
position: absolute;
width: ${o}px;
height: ${o}px;
border: ${o/10}px solid ${a};
border-radius: 50%;
border-bottom-color: transparent;
border-left-color: transparent;
transform: rotate(${45+1.8*Math.min(e/r*100,100)}deg);
transition: transform 0.5s;">
</div>
<div style="
position: absolute;
width: 100%;
text-align: center;
top: ${o/2.5}px;
font-size: ${o/7}px;
font-weight: bold;">
${e}
<div style="font-size: ${o/10}px; color: #666;">
${i.label||""}
</div>
</div>
</div>
`;t.innerHTML=l}},utils:{debounce(t,e){let r;return function i(...o){let a=()=>{clearTimeout(r),t(...o)};clearTimeout(r),r=setTimeout(a,e)}},formatBytes(t,e=2){if(0===t)return"0 Bytes";let r=Math.floor(Math.log(t)/Math.log(1024));return parseFloat((t/Math.pow(1024,r)).toFixed(e))+" "+["Bytes","KB","MB","GB"][r]},uuid:()=>"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(t){let e=16*Math.random()|0;return("x"===t?e:3&e|8).toString(16)})}};window.mw=MicroWeb;