17 Commits

Author SHA1 Message Date
212bd6b2e1 v1.3.6, update weather url 2026-02-03 16:10:52 +08:00
cda7c855fe add console config 2026-02-02 12:46:01 +08:00
ebf61d13d4 加入定时熄屏功能 2026-02-02 12:20:53 +08:00
a43690930b 加入百分比符号 2026-02-02 10:53:13 +08:00
21fa6ef70f 加入自定义配置功能 2026-02-02 10:13:01 +08:00
1899065ab0 update README 2026-02-02 09:33:47 +08:00
6502904a5a v1.3.5, update README 2026-02-02 09:26:15 +08:00
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
12 changed files with 1238 additions and 519 deletions

126
README.md
View File

@@ -1,30 +1,124 @@
# WiFi天气微 # WS2 桌面气象
esp8266版天气信息显示设备 ([English](README_en.md) | 中文)
基于ESP8266的桌面气象站能够实时显示天气信息、环境数据和时间。
![WS2气象站](docs/ws2.jpg)
![web管理页面](docs/web.jpg)
## 功能特点
- 🌤️ **实时天气显示**:获取并显示当前天气状况、温度、湿度和空气质量
- 📺 **TFT LCD彩色显示**1.54寸240x240像素彩色液晶屏
- 🕐 **多种显示模式**支持多种UI界面包括天气时钟和电子相册模式
- 🌐 **Web管理界面**内置轻量级Web服务器支持浏览器配置和管理
- 📊 **系统监控**:实时显示设备状态、内存使用和网络信息
- 📡 **WiFi连接**支持2.4GHz WiFi网络自动重连机制
## 硬件规格
- **主控芯片**ESP8266/ESP32
- **显示屏**1.54寸 TFT LCD 240×240像素
- **网络**802.11 b/g/n WiFi
## 软件架构
本项目基于MicroPython开发包含以下核心模块
- **app.py** - 主应用程序
- **config.py** - 配置管理
- **wifi_manager.py** - WiFi连接管理
- **display.py** - 显示屏控制
- **nanoweb.py** - 轻量级异步Web服务器
- **captive_portal.py** - 配置门户
## 快速开始
1. 购物网站搜: WiFi天气时钟
> 务必与商家确认可以通过usb下载固件
2. 连接设备usb下载完整版固件到ESP8266
```bash
esptool.py --port /dev/ttyUSB0 --baud 460800 write-flash --flash-size=detect 0 firmware.bin
```
### 初次配置
1. 设备启动后会创建WiFi热点 `WS2-xxxx`
2. 连接该热点,浏览器会自动跳转访问 `192.168.4.1`
3. 在配置页面输入WiFi信息
4. 设备将自动连接并获取天气信息
## Web管理界面
设备Web界面提供以下功能
### 设备状态
- 查看系统运行状态、内存使用情况
- 监控设备IP地址和运行时间
- 实时显示UUID和固件版本
### 屏幕显示
- 调节屏幕亮度
-切换显示模式
- LCD内容预览
### 系统配置
- 选择城市和地区
- 设置自动熄屏时间
- 保存/加载配置
### 高级设置
- 执行系统命令
- 查看MAC地址
- 重启设备
- 清空配置
## API接口 ## API接口
1. /ping 设备提供RESTful API接口可通过HTTP请求获取数据
2. /status ```
GET /lcd - 获取LCD设置
GET /status - 获取系统状态
GET /config - 获取配置信息
POST /exec - 执行系统命令 {cmd: "命令内容", token: "认证令牌"}
POST /lcd/set - 设置LCD参数 {brightness: 80, ui_type: "default"}
POST /config/set - 设置配置 {city: "北京", standby_time: "22:00"}
```
3. /weather ## 开发指南
/weather/?city=xxx&force=1
4. /lcd 1. 修改/src/rom下相关文件使用mpremote romfs更新
2. 自行编译micropython固件需要集成*st7789py_mpy*(未整理)
5. /lcd/set, POST ## 故障排除
6. /exec, POST ### 常见问题
```sh **Q: 设备无法连接WiFi**
# read memory free - A: 检查WiFi密码是否正确或重启设备重新配置
> *curl -H "Content-Type: application/json" -X POST -d '{"cmd":"import gc;gc.collect();R=gc.mem_free()", "token":"c6b74200"}' http://192.168.99.194/exec*
# reset **Q: 天气数据不更新**
> *curl -H "Content-Type: application/json" -X POST -d '{"cmd":"import machine; machine.reset()", "token":"c6b74200"}' http://192.168.99.194/exec* - A1: 检查网络连接,或尝试强制刷新天气数据
- A2: 个人的天气服务器失联~
## 许可证
## 参考资料 本项目采用MIT许可证
[MicroPython remote control: mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html)
## 致谢
- [MicroPython](https://micropython.org/) - 高效的Python微控制器实现
- [ST7789驱动](https://github.com/devbis/st7789py_mpy) - LCD显示屏驱动
- [captive-portal](https://github.com/anson-vandoren/esp8266-captive-portal) - 认证门户
- [Nanoweb](https://github.com/hugokernel/micropython-nanoweb) - 轻量级异步Web服务器
- [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) - mp控制终端
## 联系方式
- 项目主页: https://github.com/kicer/ws2
- 项目主页: https://iot.foresh.com/git/kicer/ws2
- 问题反馈: https://github.com/kicer/ws2/issues
- QQ联络群: 697580459

124
README_en.md Normal file
View File

@@ -0,0 +1,124 @@
# WS2 Desktop Weather Station
(English | [中文](README.md))
An ESP8266-based desktop weather station capable of displaying real-time weather information, environmental data, and time.
![WS2 Weather Station](docs/ws2.jpg)
![Web Dashboard](docs/web.jpg)
## Features
- 🌤️ **Real-time Weather Display**: Get and display current weather conditions, temperature, humidity, and air quality
- 📺 **TFT LCD Color Display**: 1.54-inch 240x240 pixel color LCD screen
- 🕐 **Multiple Display Modes**: Support for various UI interfaces, including weather clock and photo album modes
- 🌐 **Web Management Interface**: Built-in lightweight web server supporting browser configuration and management
- 📊 **System Monitoring**: Real-time display of device status, memory usage, and network information
- 📡 **WiFi Connectivity**: Support for 2.4GHz WiFi networks with automatic reconnection
## Hardware Specifications
- **Main Controller**: ESP8266/ESP32
- **Display**: 1.54-inch TFT LCD 240×240 pixels
- **Network**: 802.11 b/g/n WiFi
## Software Architecture
This project is developed based on MicroPython and includes the following core modules:
- **app.py** - Main application
- **config.py** - Configuration management
- **wifi_manager.py** - WiFi connection management
- **display.py** - Display screen control
- **nanoweb.py** - Lightweight asynchronous web server
- **captive_portal.py** - Configuration portal
## Quick Start
1. Search for "WiFi Weather Clock" on shopping websites
> Make sure to confirm with the seller that firmware can be downloaded via USB
2. Connect the device via USB and download the complete firmware to ESP8266
```bash
esptool.py --port /dev/ttyUSB0 --baud 460800 write-flash --flash_size=detect 0 firmware.bin
```
### Initial Configuration
1. The device creates a WiFi hotspot named `WS2-xxxx` upon startup
2. Connect to this hotspot and the browser will automatically redirect to `192.168.4.1`
3. Enter WiFi information on the configuration page
4. The device will automatically connect and fetch weather information
## Web Management Interface
The device web interface provides the following features:
### Device Status
- View system running status and memory usage
- Monitor device IP address and uptime
- Real-time display of UUID and firmware version
### Screen Display
- Adjust screen brightness
- Switch display modes
- LCD content preview
### System Configuration
- Select city and region
- Set auto-sleep time
- Save/load configuration
### Advanced Settings
- Execute system commands
- View MAC address
- Restart device
- Clear configuration
## API Interface
The device provides RESTful API interfaces, accessible via HTTP requests:
```
GET /lcd - Get LCD settings
GET /status - Get system status
GET /config - Get configuration information
POST /exec - Execute system commands {cmd: "Command content", token: "Authentication token"}
POST /lcd/set - Set LCD parameters {brightness: 80, ui_type: "default"}
POST /config/set - Set configuration {city: "Beijing", standby_time: "22:00"}
```
## Development Guide
1. Modify relevant files in /src/rom directory and use mpremote romfs to update
2. Custom compilation of MicroPython firmware requires integration of *st7789py_mpy* (not organized yet)
## Troubleshooting
### Common Issues
**Q: Device cannot connect to WiFi**
- A: Check if the WiFi password is correct, or restart the device to reconfigure
**Q: Weather data doesn't update**
- A1: Check network connection or try force-refreshing weather data
- A2: Personal weather server may be offline~
## License
This project is licensed under the MIT License
## Acknowledgments
- [MicroPython](https://micropython.org/) - Efficient Python implementation for microcontrollers
- [ST7789 Driver](https://github.com/devbis/st7789py_mpy) - LCD display driver
- [captive-portal](https://github.com/anson-vandoren/esp8266-captive-portal) - Captive portal
- [Nanoweb](https://github.com/hugokernel/micropython-nanoweb) - Lightweight asynchronous web server
- [mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) - MicroPython control terminal
## Contact
- Project Homepage: https://github.com/kicer/ws2
- Project Homepage: https://iot.foresh.com/git/kicer/ws2
- Issue Tracker: https://github.com/kicer/ws2/issues
- QQ Group: 697580459

BIN
docs/web.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
docs/ws2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

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,194 @@ 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))
# 定时熄屏处理功能熄屏则返回True
def standby_control():
# 获取当前时间 (格式: HH:MM)
now = time.localtime()
current_time = "{:02d}:{:02d}".format(now[3], now[4])
# 从配置中获取熄屏和唤醒时间
standby_time = config.get("standby_time")
wakeup_time = config.get("wakeup_time")
_set = config.get('brightness', 10)
# 如果设置了熄屏和唤醒时间
if standby_time and wakeup_time:
# 如果当前时间在熄屏时间之后,唤醒时间之前,则关闭屏幕
if standby_time <= wakeup_time:
if current_time >= standby_time and current_time < wakeup_time:
_set = 0
else:
if current_time >= standby_time or current_time < wakeup_time:
_set = 0
else:
_set = None
if _set is not None:
if display.brightness():
if _set == 0:
display.brightness(0)
else:
if _set > 0:
display.brightness(config.get("brightness", 10)) # 恢复亮度
# ntp时钟同步 # ntp时钟同步
def sync_ntp_time(): def sync_ntp_time():
import ntptime import ntptime
@@ -50,32 +237,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.foresh.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 +273,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 +311,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,14 +351,19 @@ 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)
# 每隔100帧更新一次UI显示 # 每隔100帧更新一次UI显示
F += 1 F += 1
if F % 100 == 0: if F % 100 == 0:
display.update_ui() standby_control() # 控制开关机
# 只在亮屏时更新显示
if display.brightness():
display.update_ui()
else:
gc.collect(); print(f'LCD.idle.mem: {gc.mem_free()}')
# 每轮清理一次内存 # 每轮清理一次内存
gc.collect() gc.collect()
@@ -193,7 +371,7 @@ async def ui_task():
# 控制帧率 # 控制帧率
now = time.ticks_ms() now = time.ticks_ms()
ts = time.ticks_diff(now, t0) ts = time.ticks_diff(now, t0)
_sT = (100-ts) if ts<90 else 10 _sT = (100 - ts) if ts < 90 else 10
await asyncio.sleep_ms(_sT) await asyncio.sleep_ms(_sT)
except Exception as e: except Exception as e:
@@ -204,34 +382,41 @@ 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():
# 禁用repl
if config.get('console') == "disable":
import os; os.dupterm(None, 1)
# 初始化液晶屏 # 初始化液晶屏
display.init_display(config.get("bl_mode")=="pwm", 7000) display.init_display(config.get("bl_mode") == "pwm", 7000)
display.brightness(int(config.get("brightness", 10))) display.brightness(int(config.get("brightness", 10)))
cb_progress("WiFi connect ...") cb_progress("WiFi connect ...")
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 +424,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.6 (20260203)")
_print_mem() _print_mem()
return True return True
@@ -186,7 +186,7 @@ class Display:
if rh is not None and rh != self.ui_data.get('rh'): if rh is not None and rh != self.ui_data.get('rh'):
self.ui_data['rh'] = rh self.ui_data['rh'] = rh
self.tft.fill_rect(110,179,40,16,0) self.tft.fill_rect(110,179,40,16,0)
self.tft.draw(self.vector_font, f' {str(rh)}%', 110,187,0xFFFF,0.5) self.tft.draw(self.vector_font, f' {str(rh)}', 110,187,0xFFFF,0.5)
if pm is not None and pm != self.ui_data.get('pm'): if pm is not None and pm != self.ui_data.get('pm'):
self.ui_data['pm'] = pm self.ui_data['pm'] = pm
self.tft.fill_rect(35,213,40,16,0) self.tft.fill_rect(35,213,40,16,0)
@@ -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%}}

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

@@ -0,0 +1,397 @@
<!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><div style="display: flex;">
<input type="time" id="standby-time-input" class="form-control" placeholder="熄屏时间" style="margin-right: 10px; flex: 1;"/>
<input type="time" id="wakeup-time-input" class="form-control" placeholder="唤醒时间" style="flex: 1;"/></div>
<small class="text-muted">分别设置熄屏和唤醒时间,留空则表示不自动熄屏</small></div>
<div class="form-group"><label class="form-label">自定义配置</label>
<div id="custom-config-container" class="custom-config-list">
<div class="custom-config-row" style="display: flex; margin-bottom: 10px;">
<input type="text" placeholder="配置项" class="form-control" id="custom-config-key" style="margin-right: 10px; flex: 1;">
<input type="text" placeholder="配置值" class="form-control" id="custom-config-value" style="flex: 1;">
</div></div></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/releases"
target="_blank"
>
ws2-firmware-v1.3.6-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: "内存使用率",
percent: true,
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);
}
if (data.wakeup_time) {
mw.val(mw.$("#wakeup-time-input"), data.wakeup_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 userCfgKey = mw.val(mw.$("#custom-config-key"));
const userCfgVal = mw.val(mw.$("#custom-config-value"));
const city = mw.val(mw.$("#city-input"));
const wakeupTime = mw.val(mw.$("#wakeup-time-input"));
const standbyTime = mw.val(mw.$("#standby-time-input"));if (!city) {
showMessage("城市名称不能为空", "error");
return;
}const configData = {
city: city,
cityid: encodeURIComponent(city),
};
if (userCfgKey !== "") {
configData[userCfgKey] = userCfgVal;
}if (standbyTime !== "" && wakeupTime !== "") {
configData.standby_time = standbyTime;
configData.wakeup_time = wakeupTime;
}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,318 +1,288 @@
<html> <html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>WiFi认证</title><style>
<head> body {
<meta charset="utf-8" /> font-family: sans-serif;
<meta name="viewport" content="width=device-width, initial-scale=1" /> background: #3498db;
<title>WiFi认证</title> width: 100%;
<style> text-align: center;
body { margin: 20px 0;
font-family: sans-serif; position: relative;
background: #3498db; }
width: 100%; p {
text-align: center; font-size: 12px;
margin: 20px 0; text-decoration: none;
position: relative; color: #fff;
} }
p { h1 {
font-size: 12px; font-size: 1.5em;
text-decoration: none; color: #525252;
color: #fff; }
} .box {
h1 { background: white;
font-size: 1.5em; width: 40ch;
color: #525252; border-radius: 6px;
} margin: 0 auto;
.box { padding: 10px 0;
background: white; position: relative;
width: 40ch; }
border-radius: 6px; input[type="text"],
margin: 0 auto; input[type="password"] {
padding: 10px 0; background: #ecf0f1;
position: relative; border: #ccc 1px solid;
} border-bottom: #ccc 2px solid;
input[type="text"], padding: 8px;
input[type="password"] { width: 80%;
background: #ecf0f1; color: #000;
border: #ccc 1px solid; margin-top: 10px;
border-bottom: #ccc 2px solid; font-size: 1em;
padding: 8px; border-radius: 4px;
width: 80%; }
color: #000; .btn {
margin-top: 10px; background: #2ecc71;
font-size: 1em; width: 80%;
border-radius: 4px; padding: 8px 0;
} color: white;
.btn { border-radius: 4px;
background: #2ecc71; border: #27ae60 1px solid;
width: 80%; margin: 20 auto;
padding: 8px 0; font-weight: 800;
color: white; font-size: 0.9em;
border-radius: 4px; cursor: pointer;
border: #27ae60 1px solid; }
margin: 20 auto; .btn:hover {
font-weight: 800; background: #27ae60;
font-size: 0.9em; }
cursor: pointer; .config-title {
} font-size: 1em;
.btn:hover { color: #525252;
background: #27ae60; margin-top: 15px;
} }
.config-title { .wifi-list {
font-size: 1em; width: 80%;
color: #525252; max-height: 200px;
margin-top: 15px; overflow-y: auto;
} margin: 10px auto;
.wifi-list { text-align: left;
width: 80%; border: 1px solid #eee;
max-height: 200px; background: #f8f8f8;
overflow-y: auto; border-radius: 4px;
margin: 10px auto; padding: 5px;
text-align: left; }
border: 1px solid #eee; .wifi-item {
background: #f8f8f8; padding: 5px;
border-radius: 4px; cursor: pointer;
padding: 5px; border-bottom: 1px solid #eee;
} display: flex;
.wifi-item { align-items: center;
padding: 5px; justify-content: space-between;
cursor: pointer; }
border-bottom: 1px solid #eee; .wifi-item:hover {
display: flex; background: #e0e0e0;
align-items: center; }
justify-content: space-between; .wifi-name {
} flex-grow: 1;
.wifi-item:hover { }
background: #e0e0e0; .wifi-signal {
} font-size: 12px;
.wifi-name { color: #888;
flex-grow: 1; }
} .refresh-btn {
.wifi-signal { position: absolute;
font-size: 12px; top: 10px;
color: #888; right: 10px;
} background: none;
.refresh-btn { border: none;
position: absolute; cursor: pointer;
top: 10px; font-size: 20px;
right: 10px; color: #7f8c8d;
background: none; width: 30px;
border: none; height: 30px;
cursor: pointer; display: flex;
font-size: 20px; align-items: center;
color: #7f8c8d; justify-content: center;
width: 30px; border-radius: 50%;
height: 30px; }
display: flex; .refresh-btn:hover {
align-items: center; background: #ecf0f1;
justify-content: center; }
border-radius: 50%; .refresh-btn:disabled {
} color: #bdc3c7;
.refresh-btn:hover { cursor: not-allowed;
background: #ecf0f1; }
} .wifi-icon {
.refresh-btn:disabled { position: relative;
color: #bdc3c7; display: inline-block;
cursor: not-allowed; width: 18px;
} height: 12px;
.wifi-icon { margin-right: 5px;
position: relative; }
display: inline-block; .wifi-icon-bar {
width: 18px; position: absolute;
height: 12px; bottom: 0;
margin-right: 5px; background: #7f8c8d;
} border-radius: 1px;
.wifi-icon-bar { }
position: absolute; .wifi-icon-bar:nth-child(1) {
bottom: 0; left: 0;
background: #7f8c8d; width: 3px;
border-radius: 1px; height: 3px;
} }
.wifi-icon-bar:nth-child(1) { .wifi-icon-bar:nth-child(2) {
left: 0; left: 5px;
width: 3px; width: 3px;
height: 3px; height: 6px;
} }
.wifi-icon-bar:nth-child(2) { .wifi-icon-bar:nth-child(3) {
left: 5px; left: 10px;
width: 3px; width: 3px;
height: 6px; height: 9px;
} }
.wifi-icon-bar:nth-child(3) { .wifi-icon-bar:nth-child(4) {
left: 10px; left: 15px;
width: 3px; width: 3px;
height: 9px; height: 12px;
} }
.wifi-icon-bar:nth-child(4) { .wifi-signal-4 .wifi-icon-bar {
left: 15px; background: #2ecc71;
width: 3px; }
height: 12px; .wifi-signal-3 .wifi-icon-bar:nth-child(1),
} .wifi-signal-3 .wifi-icon-bar:nth-child(2),
.wifi-signal-4 .wifi-icon-bar { .wifi-signal-3 .wifi-icon-bar:nth-child(3) {
background: #2ecc71; background: #f1c40f;
} }
.wifi-signal-3 .wifi-icon-bar:nth-child(1), .wifi-signal-2 .wifi-icon-bar:nth-child(1),
.wifi-signal-3 .wifi-icon-bar:nth-child(2), .wifi-signal-2 .wifi-icon-bar:nth-child(2) {
.wifi-signal-3 .wifi-icon-bar:nth-child(3) { background: #e67e22;
background: #f1c40f; }
} .wifi-signal-1 .wifi-icon-bar:nth-child(1) {
.wifi-signal-2 .wifi-icon-bar:nth-child(1), background: #e74c3c;
.wifi-signal-2 .wifi-icon-bar:nth-child(2) { }
background: #e67e22; /* 遮罩层样式 */
} .overlay {
.wifi-signal-1 .wifi-icon-bar:nth-child(1) { position: fixed;
background: #e74c3c; top: 0;
} left: 0;
/* 遮罩层样式 */ width: 100%;
.overlay { height: 100%;
position: fixed; background: rgba(0, 0, 0, 0.7);
top: 0; display: none;
left: 0; z-index: 1000;
width: 100%; justify-content: center;
height: 100%; align-items: center;
background: rgba(0, 0, 0, 0.7); }
display: none; .overlay-content {
z-index: 1000; background: white;
justify-content: center; padding: 30px;
align-items: center; border-radius: 8px;
} text-align: center;
.overlay-content { max-width: 80%;
background: white; }
padding: 30px; .spinner-small {
border-radius: 8px; width: 30px;
text-align: center; height: 30px;
max-width: 80%; border: 3px solid #f3f3f3;
} border-top: 3px solid #3498db;
.spinner-small { border-radius: 50%;
width: 30px; margin: 15px auto;
height: 30px; animation: spin 1.5s linear infinite;
border: 3px solid #f3f3f3; }
border-top: 3px solid #3498db; </style></head><body><form action="/login" method="get" class="box"><h1>WiFi 配置</h1><button
border-radius: 50%; type="button"
margin: 15px auto; class="refresh-btn"
animation: spin 1.5s linear infinite; onclick="refreshList()"
} title="刷新WiFi列表"
</style> >
</head>
<body> </button><div id="wifiList" class="wifi-list"></div><input
<form action="/login" method="get" class="box"> type="text"
<h1>WiFi 配置</h1> id="ssid"
<button placeholder="WiFi名称"
type="button" name="ssid"
class="refresh-btn" required
onclick="refreshList()" /><br /><input
title="刷新WiFi列表" type="password"
> id="pwd"
placeholder="WiFi密码"
</button> name="password"
<div id="wifiList" class="wifi-list"></div> /><br /><input
<input type="text"
type="text" placeholder="城市名称"
id="ssid" name="city"
placeholder="WiFi名称" value=""
name="ssid" /><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>
required window.onload = function () {
/><br /> fetchWifiList();// 添加表单提交事件监听
<input document
type="password" .querySelector(".box")
id="pwd" .addEventListener("submit", function (e) {
placeholder="WiFi密码" // 显示遮罩层
name="password" document.getElementById(
/><br /> "connectingOverlay",
<input ).style.display = "flex";
type="text" });
placeholder="城市名称" };
name="city" function fetchWifiList() {
value="" const listContainer = document.getElementById("wifiList");
/><br /> listContainer.innerHTML = "Loading...";
<button type="submit" class="btn">保存配置</button> fetch("/scan")
</form> .then((response) => response.json())
.then((data) => {
<!-- 遮罩层 --> if (data.networks) {
<div class="overlay" id="connectingOverlay"> data.networks.sort((a, b) => b.rssi - a.rssi);
<div class="overlay-content"> displayWifiList(data.networks);
<h2>正在连接到WiFi</h2> }
<div class="spinner-small"></div> })
<p>设备正在尝试连接,请稍候...</p> .catch((error) => {
</div> console.error("Error fetching WiFi list:", error);
</div> });
<script> }
window.onload = function () { function getSignalLevel(rssi) {
fetchWifiList(); if (rssi >= -50) return 4;
if (rssi >= -60) return 3;
// 添加表单提交事件监听 if (rssi >= -70) return 2;
document return 1;
.querySelector(".box") }
.addEventListener("submit", function (e) { function createSignalIcon(signalLevel) {
// 显示遮罩层 const icon = document.createElement("span");
document.getElementById( icon.className = `wifi-icon wifi-signal-${signalLevel}`;
"connectingOverlay", for (let i = 0; i < 4; i++) {
).style.display = "flex"; const bar = document.createElement("span");
}); bar.className = "wifi-icon-bar";
}; icon.appendChild(bar);
function fetchWifiList() { }
const listContainer = document.getElementById("wifiList"); return icon;
listContainer.innerHTML = "Loading..."; }
fetch("/scan") function displayWifiList(networks) {
.then((response) => response.json()) const listContainer = document.getElementById("wifiList");
.then((data) => { listContainer.innerHTML = "";
if (data.networks) { for (const network of networks) {
data.networks.sort((a, b) => b.rssi - a.rssi); const item = document.createElement("div");
displayWifiList(data.networks); item.className = "wifi-item";
} const nameContainer = document.createElement("div");
}) nameContainer.className = "wifi-name";
.catch((error) => { const signalLevel = getSignalLevel(network.rssi);
console.error("Error fetching WiFi list:", error); nameContainer.appendChild(createSignalIcon(signalLevel));
}); const nameText = document.createTextNode(network.ssid);
} nameContainer.appendChild(nameText);
function getSignalLevel(rssi) { const signalContainer = document.createElement("div");
if (rssi >= -50) return 4; signalContainer.className = "wifi-signal";
if (rssi >= -60) return 3; signalContainer.textContent = `${network.rssi} dBm`;
if (rssi >= -70) return 2; item.appendChild(nameContainer);
return 1; item.appendChild(signalContainer);
} item.onclick = function () {
function createSignalIcon(signalLevel) { document.getElementById("ssid").value = network.ssid;
const icon = document.createElement("span"); document.getElementById("pwd").focus();
icon.className = `wifi-icon wifi-signal-${signalLevel}`; };
for (let i = 0; i < 4; i++) { listContainer.appendChild(item);
const bar = document.createElement("span"); }
bar.className = "wifi-icon-bar"; }
icon.appendChild(bar); function refreshList() {
} const btn = document.querySelector(".refresh-btn");
return icon; btn.innerHTML = "⟳";
} btn.disabled = true;
function displayWifiList(networks) { fetchWifiList();
const listContainer = document.getElementById("wifiList"); setTimeout(() => {
listContainer.innerHTML = ""; btn.innerHTML = "";
for (const network of networks) { btn.disabled = false;
const item = document.createElement("div"); }, 1000);
item.className = "wifi-item"; }
const nameContainer = document.createElement("div"); </script></body></html>
nameContainer.className = "wifi-name";
const signalLevel = getSignalLevel(network.rssi);
nameContainer.appendChild(createSignalIcon(signalLevel));
const nameText = document.createTextNode(network.ssid);
nameContainer.appendChild(nameText);
const signalContainer = document.createElement("div");
signalContainer.className = "wifi-signal";
signalContainer.textContent = `${network.rssi} dBm`;
item.appendChild(nameContainer);
item.appendChild(signalContainer);
item.onclick = function () {
document.getElementById("ssid").value = network.ssid;
document.getElementById('pwd').focus();
};
listContainer.appendChild(item);
}
}
function refreshList() {
const btn = document.querySelector(".refresh-btn");
btn.innerHTML = "⟳";
btn.disabled = true;
fetchWifiList();
setTimeout(() => {
btn.innerHTML = "↻";
btn.disabled = false;
}, 1000);
}
</script>
</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}${i.percent?"%":""}
<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;