diff --git a/.env.example b/.env.example index 40e61f1..497d9fc 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,30 @@ QWEATHER_HOST=your-host.re.qweatherapi.com QWEATHER_KEY= WEATHER_LOCATION=118.788,32.014 WEATHER_TTL=900 + +# ---- reTerminal E1002 图片/网页服务(server.py,部署在公网;本机推送端 main.py --target push)---- +# 服务端:监听地址/端口、两条恒定 URL 路径(可改成难猜的路径)、推送令牌(为空则拒绝一切推送)、推送数据落盘路径(空=仅内存) +SERVER_HOST=0.0.0.0 +SERVER_PORT=8790 +IMAGE_PATH=/e1002.png +HTML_PATH=/e1002.html +HTML_PNG_PATH=/e1002-web.png +# 方案三需要服务器装 Chrome/Chromium;CHROME_BIN 为空则自动探测,Linux root 运行需 CHROME_EXTRA_ARGS=--no-sandbox +CHROME_BIN= +CHROME_EXTRA_ARGS= +HTML_PNG_THRESHOLD=160 +PUSH_TOKEN= +PUSH_STATE_PATH= +# 推送端(本机):服务地址(协议+域名/端口,不含路径),令牌与服务端一致 +PUSH_URL= +# 时区(服务端可能在境外 VPS,所有时间按此时区) +TZ_NAME=Asia/Shanghai +# 用量三档阈值(宽版)与 BTC K 线 +USAGE_WARN=80 +USAGE_OK=30 +PACE_TOL=10 +BTC_BAR=1H +BTC_LIMIT=72 +BTC_TTL=300 +# 服务端(server.py)三路上游的内存缓存有效期(秒,0=每次实拉):天气/BTC 复用 WEATHER_TTL/BTC_TTL,橘喵实时用 MAO_TTL +MAO_TTL=60 diff --git a/.gitignore b/.gitignore index 6ca468b..4974e88 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ __pycache__/ *.pyc .weather_cache.json .trend_cache.json +.btc_cache.json run.sh .env diff --git a/CLAUDE.md b/CLAUDE.md index a9bdaea..7624fc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co > **分支 `feature/colorful`**:新设备在黑白外支持红色,本分支把渲染升级为黑/白/红三色。当前版式为**上中下三段**:A 大时钟+日期 / 天气(定位南京·江宁);B **Claude Usage** 主角(5h/7d 用量进度条 + 时间维度对比 pace,用量吃紧转红预警);C 橘喵今日实时三列。红作**语义强调**(用量预警、涨、定位、天气太阳/闪电),仍**无真灰阶、不抖动**,全图只三种纯色。近30天趋势图当前**隐藏**(`renderer._trend_chart` 及依赖保留、未调用,随时可挂回)。 -> **分支 `feature/reterminal-e1002`**(基于 `feature/colorful`):新增第二个目标设备 **Seeed reTerminal E1002**(7.3 寸 **800×480**,E Ink **Spectra 6 原生六色**:黑/白/黄/红/绿/蓝,真机已验证六色量化干净)。该设备不接受局域网直推,而是由 **SenseCraft HMI** 云端的 **HTML 控件**按设备刷新间隔(最小 5 分钟)**每周期重新抓取**一个公网 HTTPS URL、无头浏览器截图后量化下发;不支持 ETag/304,内容不变也会整屏重绘(全刷约 17 秒)。因此本分支产出的是**一张固定 800×480 的静态页 `output/e1002/index.html` + 同目录 `dashboard.png`**,需部署到公网 HTTPS(局域网地址/明文 HTTP 云端抓不到)。渲染由 `renderer_e1002.py` 完成:继承 `DashboardRenderer` 组件、**k=1 字号与三色版完全相同(不放大,放大后字太大很傻)**,四段版式:A 时钟/天气;B 左 **Claude Usage 三行(all 5h / all 7d / fable 7d,标签沿用 statusline 写法、小写开头)**、右 **ChatGPT Usage(mock,标「示例数据」)**,紧凑单行样式(曾试过撑满高度的大卡片,被否);C **橘喵·今日实时**三列全宽(指标名 F12 作每列左上角角标;「值 F36 粗 + 右侧叠放的环比/同比」整组在列内居中——名不放在值上方,否则小字压大数字、区块上半显空;也不与值同行,否则名参与居中把整组推偏);D **橘喵·近7天**数据表(真实数据,取 30 天趋势缓存最后 7 天;**不画折线图**,用户明确不要)。六色语义:用量进度条(含边框)与 pace 各自**三档**——绿=健康(用量 ≤ `USAGE_OK` 30 / pace ≤ −`PACE_TOL` 10)、黑=正常、红=危险(用量 ≥ `USAGE_WARN` 80 / pace ≥ +10);百分比数字只在红档转红(绿小字在白底发虚)。红另用于涨/定位/太阳闪电,蓝=雨雪;**黄不用**——用户明确不要黄色进度条(曾有 60%~80% 黄「注意」档,已移除)。 +> **分支 `feature/reterminal-e1002`**(基于 `feature/colorful`):新增第二个目标设备 **Seeed reTerminal E1002**(7.3 寸 **800×480**,E Ink **Spectra 6 原生六色**:黑/白/黄/红/绿/蓝,真机已验证六色量化干净)。该设备不接受局域网直推,而是由 **SenseCraft HMI** 云端的 **HTML 控件**按设备刷新间隔(最小 5 分钟)**每周期重新抓取**一个公网 HTTPS URL、无头浏览器截图后量化下发;不支持 ETag/304,内容不变也会整屏重绘(全刷约 17 秒)。**真机验证(2026-09)**:Image 控件只在填写 URL 时抓一次快照、此后不再更新;HTML 控件填同一 URL 则每周期重抓——所以对外只用 HTML 控件。当前主形态是 **`server.py` 动态服务**(部署公网 HTTPS;局域网地址/明文 HTTP 云端抓不到):三条恒定 URL 并行供对比——方案一 `IMAGE_PATH`(默认 `/e1002.png`)每次请求**内存中现渲染 PNG**(Pillow 点阵版);方案二 `HTML_PATH`(默认 `/e1002.html`)每次请求生成 **800×480 现代网页**(`html_renderer.py`,系统/网络字体 + 内联 SVG K 线,无 JS)由平台截图——**真机实拍:文字抗锯齿灰边被平台抖动成毛边、小字与复杂汉字发糊**;方案三 `HTML_PNG_PATH`(默认 `/e1002-web.png`)同一网页由**服务端无头 Chrome 截图后按最近色(不抖动)固化为六色 PNG**(`html_shot.py`),平台拿到纯色图无从抖动,需服务器装 Chrome。**结论(2026-09-10):方案一为主力**——方案二/三的矢量字体在 125 PPI 下小字必然毛糙(抗锯齿灰边无论由平台还是我们二值化都只能变成锯齿/断笔,见 `output/e1002/font_compare.png`:矢量字体单色渲染 ≈ 抗锯齿后二值化,都不如点阵),Canvas 逐像素画也改变不了这点(Pillow 本身就是逐像素画)。方案二/三端点保留供对比,不再投入。宽版**只用点阵字体的整数倍字号 12/24/36/48**(`E1002Renderer.__init__` 把基类的 F13/F16 换成 12/24px),可用 `all(v % 12 == 0 for v in 字体尺寸)` 自查。天气/橘喵/BTC 走**服务端内存缓存 + 有效期**(`WEATHER_TTL`/`MAO_TTL`/`BTC_TTL`,默认 900/60/300 秒,0=每次实拉;请求时过期才实拉并更新时间戳,失败沿用旧值、不更新时间戳),服务**不读本机任何文件**;Claude/ChatGPT 用量**由本机 `main.py --target push` 推送原始快照**(`X-Push-Token` 鉴权),服务端内存暂存、渲染时现算 pace。静态形态 `main.py --target e1002`(`output/e1002/index.html` + `dashboard.png`)仍保留。渲染由 `renderer_e1002.py` 完成:继承 `DashboardRenderer` 组件、**k=1 字号与三色版完全相同(不放大,放大后字太大很傻)**,四段版式:A 时钟/天气;B 左 **Claude Usage 三行(all 5h / all 7d / fable 7d,标签沿用 statusline 写法、小写开头)**、右 **ChatGPT Usage(mock,标「示例数据」)**,紧凑单行样式(曾试过撑满高度的大卡片,被否);C **橘喵·今日实时**三列全宽(指标名 F12 在列顶、列内水平居中;「值 F36 粗 + 右侧叠放的环比/同比」整组在列内居中——名不与值同行,否则名参与居中把整组推偏;曾按用户要求试过左上角角标,又改回居中);D **BTC/USDT 时 K 蜡烛图(近 72 小时 = 3 天)**(免费公开行情:OKX 主源、Coinbase/Huobi 备源,`btc_api.py`,5 分钟缓存;周期/根数 `BTC_BAR`/`BTC_LIMIT` 可配,日 K 也支持;**涨绿跌红**)。此前放过 30 天折线图(用户明确不要)和「近 7 天」数据表(已被 K 线替换)。六色语义:用量进度条(含边框)与 pace 各自**三档**——绿=健康(用量 ≤ `USAGE_OK` 30 / pace ≤ −`PACE_TOL` 10)、黑=正常、红=危险(用量 ≥ `USAGE_WARN` 80 / pace ≥ +10);百分比数字只在红档转红(绿小字在白底发虚)。红另用于涨/定位/太阳闪电,蓝=雨雪;**黄不用**——用户明确不要黄色进度条(曾有 60%~80% 黄「注意」档,已移除)。 ## 命令 @@ -19,7 +19,10 @@ pip install -r requirements.txt # 仅 Pillow>=10.0;urllib 用标准库 cp .env.example .env # 首次:复制模板并填入真实凭证 + 设备 host python3 main.py # 取数据 → 渲染 → 推送(设备 host 取自 .env 的 EPD_HOST) python3 main.py --render-only # 只渲染到 output/dashboard.png,不推送(本地调试渲染时用这个) -python3 main.py --target e1002 # reTerminal E1002:渲染 800x480 六色图 + 固定尺寸 index.html 到 output/e1002/,不推送 +python3 main.py --target e1002 # reTerminal E1002 静态形态:渲染 800x480 六色图 + 固定尺寸 index.html 到 output/e1002/,不推送 +python3 server.py # reTerminal E1002 动态服务(部署公网):GET /e1002.png(内存现渲染 PNG)/ GET /e1002.html(现代网页)/ GET /e1002-web.png(网页截图固化六色)/ /health +python3 main.py --target push # 本机:把 ~/.claude/usage-snapshot.json 推送到 server.py(需 .env 的 PUSH_URL + PUSH_TOKEN) +python3 probe_server.py # 验证工具:同一 URL 是否被平台周期重抓(带访问日志) sh run.sh # cron 包装脚本:cd 项目目录 + vfox python 绝对路径 + 追加日志到 output/cron.log @@ -29,7 +32,7 @@ EPD_HOST=epd400a44.local python3 main.py 无测试、无 lint。调试渲染效果就跑 `--render-only` 看 `output/dashboard.png`。 -**配置统一走 `.env`**:所有凭证/参数放同目录 `.env`(已在 `.gitignore`,本机专用;模板见已提交的 `.env.example`)。`config.py` 顶部用零依赖加载器 `_load_dotenv()` 把 `.env` 写入 `os.environ`,再由各 `os.environ.get(...)` 读取;采用 `setdefault`,故**真实环境变量(cron/命令行注入)优先级高于 `.env`**。`config.py` 内不再保留任何明文凭证(fallback 为空串)。可配置项:`EPD_HOST`(局域网设备 mDNS host,默认 `epd400a44.local`)、`JM_BASE_API`/`JM_CLIENT_ID`/`JM_CLIENT_SECRET`/`JM_GRANT_TYPE`/`JM_TENANT_ID`、`QWEATHER_HOST`/`QWEATHER_KEY`/`WEATHER_LOCATION`(默认南京·江宁)/`WEATHER_TTL`、`USAGE_LOCAL_PATH`/`USAGE_WARN`、仅宽版的 `USAGE_OK`/`PACE_TOL`、`E1002_COLOR_STRIP`。 +**配置统一走 `.env`**:所有凭证/参数放同目录 `.env`(已在 `.gitignore`,本机专用;模板见已提交的 `.env.example`)。`config.py` 顶部用零依赖加载器 `_load_dotenv()` 把 `.env` 写入 `os.environ`,再由各 `os.environ.get(...)` 读取;采用 `setdefault`,故**真实环境变量(cron/命令行注入)优先级高于 `.env`**。`config.py` 内不再保留任何明文凭证(fallback 为空串)。可配置项:`EPD_HOST`(局域网设备 mDNS host,默认 `epd400a44.local`)、`JM_BASE_API`/`JM_CLIENT_ID`/`JM_CLIENT_SECRET`/`JM_GRANT_TYPE`/`JM_TENANT_ID`、`QWEATHER_HOST`/`QWEATHER_KEY`/`WEATHER_LOCATION`(默认南京·江宁)/`WEATHER_TTL`、`USAGE_LOCAL_PATH`/`USAGE_WARN`、仅宽版的 `USAGE_OK`/`PACE_TOL`/`BTC_BAR`/`BTC_LIMIT`/`BTC_TTL`/`MAO_TTL`、`E1002_COLOR_STRIP`。 **定时部署**:由 macOS **launchd LaunchAgent** 触发 `run.sh`(不再用 cron——cron 在现代 macOS 上易被 TCC/权限静默拦截,launchd 是系统原生调度器)。LaunchAgent plist 装在 `~/Library/LaunchAgents/com.jm.eink-push.plist`(机器专用,未入库,等价于 `run.sh`/`.env` 的本机产物),`StartInterval=300` + `RunAtLoad=true`:登录/重启后立即跑一次,此后每 5 分钟一次。用 LaunchAgent(非 LaunchDaemon)是因为脚本依赖当前用户目录下的文件(`~/.claude/usage-snapshot.json`、`.env`),必须在用户会话下运行——机器需已登录(或开了自动登录)才能保证开机即触发。`run.sh` 内部逻辑不变:cron 不继承登录环境,故脚本内 python 写死 vfox 绝对路径、并 `cd` 到项目目录后追加日志到 `output/cron.log`(`cd` 后 `config.py` 才能就近找到 `.env`);launchd 自身的 stdout/stderr 另落 `output/launchd.log`(正常应为空,非空说明进程本身起不来,而非业务报错)。常用排查:`launchctl list | grep com.jm.eink-push` 看是否常驻及最近退出码,`launchctl kickstart -k gui/$(id -u)/com.jm.eink-push` 手动立即触发一次;改 plist 后需 `launchctl bootout` + `bootstrap` 重新加载才生效(热改文件不会自动生效)。每 5 分钟触发:业务数据每次都刷新,天气靠缓存、用量读本地文件,避免高频打接口(见 `data.py`)。 @@ -37,11 +40,12 @@ EPD_HOST=epd400a44.local python3 main.py `main.py` 串联三层,各层职责单一、低耦合: -- **`data.py`** — 唯一数据入口 `get_dashboard_data()`,返回固定结构的 dict:`date`/`weather`/`mao`/`usage`。日期时间用真实系统时间;橘喵今日经营由 `_mao()` 经 `jm_api`、天气由 `_weather()` 经 `weather_api`(和风,定位南京·江宁)、Claude 用量由 `_usage()` 经 `usage_local`,**任何异常都回退占位 `"--"`,不回退 mock**。改数据保持返回结构不变即可(renderer 依赖其 key:`weather.icon` 为和风图标代码;`mao.cols[].hb/tb` 为环比/同比 `(direction, text)`,direction 为 `None` 时不画三角;`usage.bars[]` 为 `{k(短标签,小屏用), label(长标签 all 5h/fable 7d,宽版用), pct(或 None), time_pct?, reset?, scoped}`、`usage.warn`/`usage.ok`/`usage.pace_tol` 为红/绿/pace 阈值(小屏只用 warn)、`usage.updated` 为快照落盘时刻;`usage_gpt` 与 `usage` 同构,目前是 **mock**(`mock: True`;三行故意取 88/71/22 以常驻红/黑/绿三档),接真实数据源时替换 `_usage_gpt()` 即可)。注:`mao.trend` 两个版面都不画折线;宽版用 `_recent_days(trend, 7)` 得到 `mao.week`(结构 `{dates, rows:[(name, [text...], total)]}`)做「近7天」数据表。 - - **缓存**(为 launchd 每 5 分钟独立进程而设,必须落盘):天气 `.weather_cache.json`(TTL=`WEATHER_TTL`,默认 15min)、趋势 `.trend_cache.json`(按日期 key,每天只拉一次);两者拉取失败沿用旧缓存。**用量不自建缓存**——直接读 statusline 生产的本地快照 `~/.claude/usage-snapshot.json`(见 `usage_local`),失败即 `"--"`。两个缓存文件已在 `.gitignore`。 +- **`data.py`** — 两种形态:本机 `get_dashboard_data()`(带文件缓存,用量读本地快照);服务端用纯拉取函数 `weather_fetch()`/`mao_fetch()`/`btc_fetch()` + `usage_from_raw(now, raw, windows, title)`(`_usage(now, raw=)`/`_usage_gpt(now, raw=)` 都基于它,raw=None 时前者读本地快照、后者用 `gpt_mock_raw()`)。所有「现在」用 `now_tz()`、时间戳格式化用 `_local()`(`TZ=ZoneInfo(config.TZ_NAME)`),本机与境外服务端结果一致。返回固定结构的 dict:`date`/`weather`/`mao`/`usage`/`usage_gpt`/`btc`。日期时间用真实系统时间;橘喵今日经营由 `_mao()` 经 `jm_api`、天气由 `_weather()` 经 `weather_api`(和风,定位南京·江宁)、Claude 用量由 `_usage()` 经 `usage_local`,**任何异常都回退占位 `"--"`,不回退 mock**。改数据保持返回结构不变即可(renderer 依赖其 key:`weather.icon` 为和风图标代码;`mao.cols[].hb/tb` 为环比/同比 `(direction, text)`,direction 为 `None` 时不画三角;`usage.bars[]` 为 `{k(短标签,小屏用), label(长标签 all 5h/fable 7d,宽版用), pct(或 None), time_pct?, reset?, scoped}`、`usage.warn`/`usage.ok`/`usage.pace_tol` 为红/绿/pace 阈值(小屏只用 warn)、`usage.updated` 为快照落盘时刻;`usage_gpt` 与 `usage` 同构,目前是 **mock**(`mock: True`;三行故意取 88/71/22 以常驻红/黑/绿三档),接真实数据源时替换 `_usage_gpt()` 即可)。注:`_mao` 仍会拉 `mao.trend`,但两个版面当前都不用它。宽版另有 `btc`:`_btc()` 经 `btc_api.get_candles(BTC_BAR, BTC_LIMIT)` 取 K 线(默认 1H×72),结构 `{symbol, bar, src, candles:[{t, o, h, l, c}], last, chg, chg_label}`,chg 时 K 取相对 24 根前收盘(24h)、日 K 取相对上一根;缓存 `.btc_cache.json`(TTL=`BTC_TTL` 默认 5 分钟,周期变更即失效,失败沿用旧缓存,仍无则 candles 为空 → 面板 `--`)。 + - **缓存**(为 launchd 每 5 分钟独立进程而设,必须落盘):天气 `.weather_cache.json`(TTL=`WEATHER_TTL`,默认 15min)、趋势 `.trend_cache.json`(按日期 key,每天只拉一次)、BTC K 线 `.btc_cache.json`(TTL=`BTC_TTL`,默认 5min);三者拉取失败沿用旧缓存。**用量不自建缓存**——直接读 statusline 生产的本地快照 `~/.claude/usage-snapshot.json`(见 `usage_local`),失败即 `"--"`。两个缓存文件已在 `.gitignore`。 - **用量的 pace(时间维度对比)**:`_usage()` 每次按当前时间**现算** `time_pct=(窗口已流逝/窗口长)`、`pace=pct−time_pct`(>0 超前↑ / <0 节余↓);故即使 `utilization` 来自可能滞后的本地缓存,pace 仍随时钟准确推进。5h 窗口 18000s、7d 窗口 604800s。 - **`jm_api.py`** — jm-devops 后端统计接口客户端,**设备密钥授权**(`grant_type=client_secret`,免验证码)。先用 `CLIENT_ID + CLIENT_SECRET` 调 `POST /auth/login` 换 token(进程内缓存、按 `expire_in` 提前刷新),再带 `Authorization: Bearer ` + `clientid` 头调 `/system/statistics/{realtime,recent-days}`;遇 401 清 token 重登一次重试。token 字段为蛇形 `access_token`/`expire_in`。注:历史上曾免鉴权直调、更早曾用账号密码+验证码 OCR 登录(ddddocr 不稳定),现统一为 client_secret 授权——密钥即长期凭证,务必走 HTTPS、优先用 `JM_CLIENT_SECRET` 环境变量、可在后端 `sys_client` 轮换。 - **`weather_api.py`** — 和风天气(QWeather)客户端。用**用户专属 API Host**(`QWEATHER_HOST`)+ `X-QW-Api-Key` 头鉴权;响应 **gzip 压缩**(按 magic number 手动解压)、返回 `code` 为**字符串**。`get_now()` 取实时(`now.text` 中文天气 / `now.temp` / `now.icon` 图标代码),`get_today()` 取 `/3d` 的 `daily[0]` 今日温区。`QWEATHER_HOST`/`QWEATHER_KEY` 未配置时直接抛错→天气区显示 `"--"`。 +- **`btc_api.py`** — 比特币 K 线客户端,免费公开接口、无需密钥,`get_candles(bar, limit)`,周期代号 `BARS = {"1H", "1D"}` 映射到各源写法,逐源兜底 `SOURCES = (okx, coinbase, huobi)`:OKX `/api/v5/market/candles?instId=BTC-USDT&bar=`(主源,本地直连可达且最新)、Coinbase `/products/BTC-USD/candles?granularity=`、Huobi `/market/history/kline?period=`;各源字段顺序不同(OKX/Huobi 最新在前,Coinbase 为 [time, low, high, open, close]),统一归一为升序 `{ts, o, h, l, c}`。Binance 在本地区返回 restricted location,不列入。 - **`usage_local.py`** — Claude Code 用量本地读取,**不发任何网络请求**(**生产者/消费者**模式):只读 `config.USAGE_LOCAL_PATH`(默认 `~/.claude/usage-snapshot.json`)。**生产者 = 打过补丁的 claude-statusline**(本机 `~/.claude/statusline.sh`):把每次 Claude Code 经 stdin 喂来的**权威实时**额度落盘为该快照(schema 同 `/api/oauth/usage`:`five_hour`/`seven_day` 的 `utilization` + `resets_at`),并额外写入 **`seven_day_fable`**(Fable 模型限定的 7 天额度——stdin 里没有,只在 `/api/oauth/usage` 返回的 `limits[]` 中 `scope.model.display_name == "Fable"` 那项;statusline 在 stdin 分支也会每 5 分钟刷新一次 API 缓存 `/tmp/claude/statusline-usage-cache.json` 以拿到它,无 token/断网时该键为 `null`);`data.USAGE_WINDOWS` 用 `scoped=True` 标记这一行,400×300 小屏 `render()` 跳过 scoped 行、800×480 宽版显示。本模块作消费者读取。活跃使用时快照几乎持续刷新、准确;空闲时停在最后一次。文件缺失/损坏→用量区 `"--"`。注:`resets_at` 可能是 **epoch 数字**(Claude Code stdin 给的就是 epoch)或 ISO 串,`data._parse_reset_ts` 两者兼容。(曾评估直调 `/api/oauth/usage`,改为读本地快照以免网络请求。) - **`renderer.py`** — `DashboardRenderer.render(data)` 返回 PIL `'RGB'` 模式、**仅黑/白/红三色**图;组件层提供可覆写钩子/参数供宽版复用:`_usage_colors()`(用量行 填充/边框/文字 三色元组)、`_pace_color()`(pace 数值与三角颜色)、`_usage_row(label_w=)`、`_weather_icon(wet=)`(降水元素颜色)、`_label(x0,x1)`、`_cmp_at()`(环比/同比左对齐版,`_cmp_line` 居中版基于它);字体另有 `F36`(3 倍整数缩放)。`render_to_file()` 落盘。布局硬编码为**上中下三段**,两道 `hdash` 横规分隔(y=90 / 200): - **A 顶部**(0–90):大时钟(F48)+日期(左)/ 天气(右:红定位水滴 + 温度 + 天气·温区 + 全矢量图标)。 @@ -51,7 +55,12 @@ EPD_HOST=epd400a44.local python3 main.py - **`_trend_chart`(当前隐藏)**:近30天三指标折线,用 `_styled_polyline()` 按 solid/dashed/dotted 区分、主指标(流水)红线,各指标按自身极值独立归一化。已保留但 `render()` 未调用,想恢复即在 C 前挂回。 - **天气图标全矢量手绘**(无图片资源):`_weather_category()` 把和风 icon 代码归并为 8 类(sunny/clear_night/cloudy/overcast/thunder/rain/snow/fog,识别不了回退 cloudy),再由 `_weather_icon()` 用 `_sun`/`_cloud` 等基元按 1bit 描边画出;改天气展示从这里动。 - **`pusher.py`** — 直连局域网墨水屏设备(喵喵固件,开源:https://gitee.com/gxp666111/miaomiao,`web/` 即设备自带管理页,可浏览器打开 `http:///` 调试同一套接口)。`push_image()` 先调 `GET /images` 拿画廊已存图片名单,逐个 `POST /delete_image?name=`清空(单张删除失败不阻塞后续),再 `POST /upload`(`Content-Type: image/png`,body 为原始 PNG 字节,非 multipart)触发设备转换+刷屏。**清空画廊是刻意设计**:设备把每次 `/upload` 都追加存一份到画廊(`GET /images` 可见),不清理会越推越多、且设备当前 `current_mode` 为 slideshow 会轮播到旧图;先清后传使画廊恒为 1 张,等价于始终静态展示最新一张。**清理非核心**:拉取画廊列表本身失败也不阻塞后续 `/upload`(跳过清理,直接推)。`_request()` 对连接类异常(`urllib.error.URLError`,含 mDNS `.local` 域名解析瞬时失败)内置重试(`RETRY_TIMES=2`,间隔 `RETRY_DELAY=2s`)——mDNS 基于组播 UDP,偶发丢包很正常,单次失败不该让整轮推送直接跳过。注:历史上曾走 Zectrix 公网 API(`POST /devices/{id}/display/image` + 设备 MAC + `ZECTRIX_API_KEY`),现统一为局域网直连。 -- **`renderer_e1002.py`** — reTerminal E1002 目标:`E1002Renderer(DashboardRenderer)` 以 **k=1** 复用组件(字号 12/24/48 与三色版一致),只重写版式与配色钩子。四段、三道横规,各段 y 由上一段实际底部推算:A 顶部 0–90 时钟/日期左、天气右(贴 x=790);B 用量 `_usage_block`×2(左 `usage` 10–386|竖规 x=400|右 `usage_gpt` 414–790):`_label` 标题 + 右侧「快照 HH:MM」或 mock 的「示例数据」,行用基类紧凑 `_usage_row`(行距 31,宽版把 `bar["label"]` 长标签代入 `k`,`label_w` 按本块最长标签自适应);C `_realtime` 今日实时三列全宽:指标名 F12 贴列左上角(x=列左+12,y=y0+22)作角标;「值 F36 粗 + 右侧叠放的环比/同比(`_cmp_at` 左对齐版,行距 18)」按墨迹中心对齐到组中线 y0+56、整组在列内水平居中(不含角标);D `_week_table` 近 7 天表(`mao.week`:指标名列 + 7 个日期 + 合计列,行 单量/流水/毛利,全 F12 右对齐、合计伪粗体、合计列前一道竖点规、表头下一道点规;无数据居中 `--`)。配色钩子覆写:`_usage_colors` 三档(≥`USAGE_WARN` 红|≤`USAGE_OK` 绿|其间黑;填充与边框同色,百分比文字仅红档转红)、`_pace_color` 三档(≥+`PACE_TOL` 红|≤−`PACE_TOL` 绿|其间黑),阈值由 `usage.ok`/`usage.pace_tol`(来自 config)在 `_usage_block` 内注入。天气图标 `wet=BLUE` 让雨滴/雪花着蓝。可选六色测试色条 `_color_strip`(`E1002_COLOR_STRIP=1` 打开,默认关)。`write_page()` 写出固定 800×480 的 `index.html`(零边距、`image-rendering: pixelated`、PNG 带时间戳参数防缓存)。**为何固定尺寸而非自适应**:云端截图视口/DPR 不可控,自适应会重排+抗锯齿产生中间色再被平台抖动;固定页里的 1:1 PNG 每个像素本就是六色之一,截图零损失——已用无头 Chrome `--window-size=800,480` 截图验证与 PNG 逐像素一致。设备端 HTML 控件须铺满整个画布。历史(均已弃用):k=2 整体放大版;「左用量/右今日实时/下 30 天折线」版;「用量大卡片撑满」版。 +- **`renderer_e1002.py`** — reTerminal E1002 目标:`E1002Renderer(DashboardRenderer)` 以 **k=1** 复用组件(字号 12/24/48 与三色版一致),只重写版式与配色钩子。四段、三道横规,各段 y 由上一段实际底部推算:A 顶部 0–90 时钟/日期左、天气右(贴 x=790);B 用量 `_usage_block`×2(左 `usage` 10–386|竖规 x=400|右 `usage_gpt` 414–790):`_label` 标题 + 右侧「快照 HH:MM」或 mock 的「示例数据」,行用基类紧凑 `_usage_row`(行距 31,宽版把 `bar["label"]` 长标签代入 `k`,`label_w` 按本块最长标签自适应);C `_realtime` 今日实时三列全宽:指标名 F12 贴列左上角(x=列左+12,y=y0+22)作角标;「值 F36 粗 + 右侧叠放的环比/同比(`_cmp_at` 左对齐版,行距 18)」按墨迹中心对齐到组中线 y0+56、整组在列内水平居中(不含角标);D `_btc_chart` BTC K 线(`data.btc`,标题按 `bar` 显示「时K/日K」:右侧最新价 F12 粗 + 口径 `chg_label`(24h/1d)+ 涨跌三角 + 涨跌幅,涨绿跌红;每根蜡烛 1px 影线 + 实心实体(宽≈槽位 60% 取奇数)、收≥开 绿 否则 红;右侧 66px 标签列放最高/最低/最新价,最新价横贯一道点规、标签避让顶底;底部基线 + 首/中/末时间标签 `t`;无数据居中 `--`)。用量块 `_usage_block` 返回末条进度条底部,B/C 分割线取其 +16px(用户反馈太挤)。配色钩子覆写:`_usage_colors` 三档(≥`USAGE_WARN` 红|≤`USAGE_OK` 绿|其间黑;填充与边框同色,百分比文字仅红档转红)、`_pace_color` 三档(≥+`PACE_TOL` 红|≤−`PACE_TOL` 绿|其间黑),阈值由 `usage.ok`/`usage.pace_tol`(来自 config)在 `_usage_block` 内注入。天气图标 `wet=BLUE` 让雨滴/雪花着蓝。可选六色测试色条 `_color_strip`(`E1002_COLOR_STRIP=1` 打开,默认关)。`write_page()` 写出固定 800×480 的 `index.html`(零边距、`image-rendering: pixelated`、PNG 带时间戳参数防缓存)。**为何固定尺寸而非自适应**:云端截图视口/DPR 不可控,自适应会重排+抗锯齿产生中间色再被平台抖动;固定页里的 1:1 PNG 每个像素本就是六色之一,截图零损失——已用无头 Chrome `--window-size=800,480` 截图验证与 PNG 逐像素一致。设备端 HTML 控件须铺满整个画布。历史(均已弃用):k=2 整体放大版;「左用量/右今日实时/下 30 天折线」版;「用量大卡片撑满」版。 +- **`server.py`** — E1002 动态服务(stdlib `ThreadingHTTPServer`,无第三方依赖)。`build_data()` → `(data, 各路状态)`:日期按 `config.TZ_NAME` 时区现算;三路上游经 `_cached(key, ttl, fetch, fallback)`——`STATE.last_good[key]` 未过期(`time - STATE.fetched_at[key] < ttl`)直接用,过期才调 `data.weather_fetch()`/`data.mao_fetch(now, with_trend=False)`/`data.btc_fetch()`,成功更新数据与时间戳,失败沿用旧值(状态 `stale`)、从未成功用占位(`none`);日志每行末尾 `[weather:cache(12s) mao:fetched btc:cache(3s)]` 可直接看命中情况,`/health` 有 `cache_age_s`/`ttl_s`;用量取 `STATE.raw`(推送暂存)→ `data._usage(now, raw=…)`/`data._usage_gpt(now, raw=…)`,未推送时 Claude 全 `--`、ChatGPT 用 mock。端点:`GET IMAGE_PATH` → `render_png_bytes()`(`_RENDER_LOCK` 串行化,Pillow 渲染器非线程安全);`GET HTML_PATH` → `html_renderer.render_html()`;`GET HTML_PNG_PATH` → `html_shot.screenshot_html()`+`quantize6()`(`?raw=1` 跳过量化);`GET /health` JSON;`POST /push/usage|/push/usage_gpt`(body=原始快照 JSON,≤64KB,头 `X-Push-Token`==`PUSH_TOKEN`,为空拒绝);`GET /push/*` 查看暂存(同令牌)。所有响应 `Cache-Control: no-store`;每请求打一行日志(含 `CF-Connecting-IP`)。`PUSH_STATE_PATH` 非空时推送数据落盘(原子替换),重启恢复;图片永不落盘。实测:三路全实拉约 1.1~1.3s(天气 0.28s、橘喵 0.06s、OKX 0.7s 串行),全部命中缓存约 0.02s(渲染 20ms + 编码 14ms)。 +- **`html_shot.py`** — 方案三:`screenshot_html(html)` 用无头 Chrome(`find_chrome()` 自动探测或 `CHROME_BIN`;Linux root 需 `CHROME_EXTRA_ARGS=--no-sandbox`)把 HTML 截成 800×480,临时目录用后即删,`_SHOT_LOCK` 串行化;**切勿传 `--user-data-dir` 指向全新目录**——新版 `--headless=new` 对空 profile 首启会卡死到超时(实测 A/B/D/E 全超时、只有不带它的 C 2 秒完成)。`quantize6(img)`:每通道二值化(阈值 `HTML_PNG_THRESHOLD` 默认 160,偏向保墨迹,细笔画浅灰边归黑而非丢白)→ Pillow 调色板 `quantize(dither=NONE)` 映射到 `SPECTRA6`,输出保证只含六色。服务端 `?raw=1` 返回未量化截图供对照。实测单次约 3.3s(Chrome 启动为主)。 +- **`html_renderer.py`** — 方案二网页版:纯 HTML+CSS+内联 SVG、无 JS,数据服务端写死;字体栈 PingFang/微软雅黑/Noto Sans SC + Google Fonts 兜底(平台截图机若无中文字体会掉字,能否加载取决于其出网能力);颜色只用六色纯值;配色规则与 PNG 版一致(用量/pace 三档、涨红跌黑、K 线涨绿跌红),版式同构。K 线标题右侧价格用 ``(`h3 b::before` 的方块装饰只给标题)。 +- **`push_client.py`** — 本机推送端:读 `~/.claude/usage-snapshot.json` 原文 `POST {PUSH_URL}/push/usage`,直连、连接类异常重试 2 次、4xx/5xx 直接报错;ChatGPT 暂无本机源不推(将来按 `data.GPT_WINDOWS` 键组 dict 推 `/push/usage_gpt`)。 +- **`probe_server.py`** — 验证工具(不参与正常流程):极简 HTTP 服务,`/probe.png` 每 `--every` 秒重绘一张带大号时刻+序号的 800×480 探针图,响应 `Cache-Control: no-store`,每次请求打印时间/UA/来源 IP(含 `CF-Connecting-IP`)。用途:配合 `cloudflared tunnel --url http://localhost:8787` 暴露成公网 HTTPS,填入 SenseCraft 的 Image 控件,凭**访问日志**判断平台对同一 URL 是否每个刷新周期重抓(成立则可直接覆盖同一 URL 的图片,省掉 HTML 页那一层)。**结论(已验证)**:Image 控件不重抓、HTML 控件重抓,见分支说明。 - **`config.py`** — 顶部 `_load_dotenv()` 先加载同目录 `.env`,再定义 API、设备、画布尺寸、字体路径、推送参数;凭证 fallback 为空串,真实值来自 `.env`/环境变量。 ## 墨水屏渲染约束(改 renderer.py 必读) @@ -69,4 +78,25 @@ EPD_HOST=epd400a44.local python3 main.py ## 注意 - 所有凭证均在 `.env`(不提交);`config.py` 不再内置明文 fallback。分享代码时给出 `.env.example` 即可,真实 `.env` 切勿入库。 -- `.gitignore` 已忽略:`output/`、`__pycache__/`、`*.pyc`、两个缓存文件 `.weather_cache.json`/`.trend_cache.json`、本机专用的 `run.sh`、含凭证的 `.env`(模板 `.env.example` 入库)。改缓存逻辑后想强制重拉,删掉对应缓存文件即可。 +- `.gitignore` 已忽略:`output/`、`__pycache__/`、`*.pyc`、两个缓存文件 `.weather_cache.json`/`.trend_cache.json`/`.btc_cache.json`、本机专用的 `run.sh`、含凭证的 `.env`(模板 `.env.example` 入库)。改缓存逻辑后想强制重拉,删掉对应缓存文件即可。 + +## 验证与运维手法(会话沉淀) + +- **像素回归**:改 `renderer.py` 组件后,用固定夹具比对 HEAD 版与工作区版 `DashboardRenderer` 的 k=1 输出逐像素相同 + (`git show HEAD:renderer.py > /tmp/orig/renderer.py`,切换 `sys.path` 分别 import 渲染,`ImageChops.difference(a,b).getbbox() is None`); + 六色版渲染后 `set(img.getcolors())` 必须 ⊆ `SPECTRA6`。夹具需覆盖:pct=None、≥warn 红档、≤ok 绿档、direction=None、空 candles。 +- **HTML→截图逐像素核验**:`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 --window-size=800,480 --screenshot=/tmp/s.png file://…`;**勿加指向空目录的 `--user-data-dir`**(卡死到超时)。 +- **server.py 本机运维**:启动 `nohup env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy -u ALL_PROXY python3 server.py >> output/server.log 2>&1 &`; + 重启前按 PID 杀干净:`for p in $(lsof -nP -iTCP:8790 -sTCP:LISTEN -t); do kill $p; done`——`pkill -f` 曾漏杀,且 `*:8790` 与 `127.0.0.1:8790` 两个监听可并存、旧代码继续应答(表现为新端点 404)。 +- **cloudflared 快速隧道**(免账号,地址每次重启都变,需到 SenseCraft 更新 URL): + `nohup cloudflared tunnel --url http://127.0.0.1:8790 --no-autoupdate > output/cloudflared.log 2>&1 &`, + 地址 `grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' output/cloudflared.log`(已写入 `output/tunnel_url.txt`)。 +- **shell 坑**:本机是 fish——`env -u …` 不能存进变量再当命令执行;含中文的 Python heredoc 用 `python3 -X utf8 - <<'EOF'`,否则 stdin 解码报错;`gh` 未登录且 GitHub API 匿名限流,取源码走 `curl raw.githubusercontent.com`。 +- **statusline 补丁验证**(`~/.claude/statusline.sh`,备份 `statusline.sh.bak-*`):用假 HOME 喂 stdin 不污染真实快照—— + `echo '' | HOME=/tmp/fakehome bash ~/.claude/statusline.sh`,查 `/tmp/fakehome/.claude/usage-snapshot.json` 是否含 `seven_day_fable`。 +- **定时任务**:`run.sh` 末尾已追加 `main.py --target push`(vfox python 绝对路径,`|| true`),用量随喵喵推送每 5 分钟同步到 server.py;推送暂存落盘 `output/push_state.json`。 +- **上游配额**:server 三路上游已有 TTL 内存缓存(天气默认 15 分钟 → 和风约 192 次/天);改有效期只动 `.env`,不动代码。 +- **SenseCraft 未公开后端**(从网页前端 bundle 提取,`https://sensecraft-hmi-api.seeed.cc`,头 `Authorization: `,token 在 localStorage `user-storage`): + `POST /api/v1/oss/file/upload`(multipart `file`+`type=image`→`file_url`)→ `POST /api/v2/user/page`(`{pages:[{name,type:img|url|layout,data,dither,thumbnail,resolution:"800x480"}]}`) + → `POST /api/v2/user/playlist/upsert_pages` → `POST /api/v2/user/device/down_link`(`{mac_address,playlist_id,type,refresh_interval?,deep_sleep_enabled?}`); + 另有 `POST /api/v2/user/device/config`、`POST /render/preview`(`{url,img_format,resolution,dither}`)。**未采用**(未公开、可随时变更),仅作备选记录。 diff --git a/README.md b/README.md index b7433c5..da86203 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ eink-push/ ├── main.py # 入口:取数据 → 渲染 → 推送(--target e1002 时改为产出 HTML 页) ├── renderer_e1002.py# reTerminal E1002:800x480 六色版式 + 固定尺寸 index.html 生成 +├── server.py # E1002 动态服务:恒定 URL,每次请求现取数据、内存现渲染(PNG / HTML 两方案)+ 用量推送接口 +├── html_renderer.py # 方案二:800x480 现代网页版(无 JS,内联 SVG K 线) +├── html_shot.py # 方案三:网页 → 无头 Chrome 截图 → 最近色固化六色 PNG +├── push_client.py # 本机推送端:用量快照 → server.py +├── btc_api.py # BTC K 线(OKX/Coinbase/Huobi 免费接口,逐源兜底) +├── probe_server.py # 验证工具:平台对同一 URL 是否周期重抓 ├── config.py # 配置:设备、天气、用量、字体、尺寸、推送参数、缓存路径 ├── data.py # 数据层:拉真实数据并映射,失败回退占位 "--"(不回退 mock);天气/趋势缓存 ├── jm_api.py # jm-devops 后端统计接口客户端(设备密钥 client_secret 授权 + token 缓存) @@ -48,6 +54,10 @@ python3 main.py # 渲染并推送(设备取 .env 的 ZECTRIX_DEV python3 main.py --render-only # 只渲染到 output/dashboard.png,不推送(本地调试渲染用) python3 main.py --target e1002 # reTerminal E1002(800x480 六色):产出 output/e1002/index.html + dashboard.png, # 部署到公网 HTTPS 后填入 SenseCraft HMI 的 HTML 控件(云端每周期重新抓取) +python3 server.py # reTerminal E1002 动态服务(部署公网 HTTPS):/e1002.png 每次请求内存渲染 PNG,/e1002.html 现代网页, + # /e1002-web.png 网页经服务端无头 Chrome 截图后固化六色(需装 Chrome); + # 天气/橘喵/BTC 实时拉取,Claude/ChatGPT 用量由本机推送 +python3 main.py --target push # 本机:推送 ~/.claude/usage-snapshot.json 到 server.py(.env: PUSH_URL + PUSH_TOKEN) ``` 环境变量可覆盖配置(详见 `config.py`): @@ -86,6 +96,7 @@ USAGE_LOCAL_PATH=~/.claude/usage-snapshot.json USAGE_WARN=80 本项目作消费者读取。活跃使用时快照持续刷新、准确;空闲时停在最后一次。**需先安装补丁版 statusline** (见其仓库 `bin/statusline.sh` 的 usage snapshot 段 + `node bin/install.js`)。 - **日期 / 时间 / 上次更新**:真实系统时间。 +- **比特币 K 线**(仅 800×480 宽版底部面板,默认时 K 近 72 小时,`BTC_BAR`/`BTC_LIMIT` 可配):免费公开接口,无需密钥,逐源兜底 OKX → Coinbase → Huobi(`btc_api.py`),5 分钟文件缓存 `.btc_cache.json`,失败沿用旧缓存。涨(收≥开)绿、跌红。 > `data.py` 负责把响应映射成渲染结构;**任何接口失败都回退占位 `"--"`,不回退 mock**。 diff --git a/btc_api.py b/btc_api.py new file mode 100644 index 0000000..d01dc6c --- /dev/null +++ b/btc_api.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +比特币 K 线行情客户端 —— 免费、无需密钥的公开接口,按顺序尝试多个源,首个成功即返回。 + +源(均为公开 REST,无鉴权): + 1. OKX GET /api/v5/market/candles?instId=BTC-USDT&bar=<1H|1D> —— 主源,本地直连可达、数据最新 + 2. Coinbase GET /products/BTC-USD/candles?granularity=<3600|86400> —— 备源(USD 计价,与 USDT 数值几乎一致) + 3. Huobi GET /market/history/kline?period=<60min|1day>&symbol=btcusdt —— 备源 + (Binance 在本地区返回 restricted location,不列入。) + +周期用统一代号 bar:"1H"(时 K)/ "1D"(日 K),由 BARS 映射到各源的写法。 +统一输出:按时间**升序**的 list[dict],每根 {"ts": epoch 秒, "o", "h", "l", "c"}(float),最后一根为当前未收盘的那根。 +任何源失败抛异常并尝试下一个;全部失败抛 RuntimeError,由 data 层决定沿用缓存或占位。 +""" +import json +import urllib.request + +TIMEOUT = 8 +UA = "eink-push/1.0 (+https://github.com/)" + +# 周期代号 → 各源参数:(OKX bar, Coinbase granularity 秒, Huobi period) +BARS = {"1H": ("1H", 3600, "60min"), "1D": ("1D", 86400, "1day")} + + +def _get_json(url): + req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _okx(bar, limit): + j = _get_json(f"https://www.okx.com/api/v5/market/candles?instId=BTC-USDT&bar={BARS[bar][0]}&limit={limit}") + if str(j.get("code")) != "0": + raise RuntimeError(f"okx code={j.get('code')} msg={j.get('msg')}") + rows = j["data"] # 最新在前:[ts_ms, o, h, l, c, vol, ...] + out = [{"ts": int(r[0]) // 1000, "o": float(r[1]), "h": float(r[2]), "l": float(r[3]), "c": float(r[4])} for r in rows] + return sorted(out, key=lambda x: x["ts"]) + + +def _coinbase(bar, limit): + rows = _get_json(f"https://api.exchange.coinbase.com/products/BTC-USD/candles?granularity={BARS[bar][1]}") + # 最新在前:[time, low, high, open, close, volume] + out = [{"ts": int(r[0]), "o": float(r[3]), "h": float(r[2]), "l": float(r[1]), "c": float(r[4])} for r in rows] + return sorted(out, key=lambda x: x["ts"])[-limit:] + + +def _huobi(bar, limit): + j = _get_json(f"https://api.huobi.pro/market/history/kline?period={BARS[bar][2]}&size={limit}&symbol=btcusdt") + if j.get("status") != "ok": + raise RuntimeError(f"huobi status={j.get('status')} err={j.get('err-msg')}") + out = [{"ts": int(r["id"]), "o": float(r["open"]), "h": float(r["high"]), "l": float(r["low"]), "c": float(r["close"])} + for r in j["data"]] + return sorted(out, key=lambda x: x["ts"]) + + +SOURCES = (("okx", _okx), ("coinbase", _coinbase), ("huobi", _huobi)) + + +def get_candles(bar="1H", limit=36): + """返回 (源名, 升序 K 线列表)。bar 为 "1H"/"1D"。逐源尝试,全部失败抛 RuntimeError。""" + if bar not in BARS: + raise ValueError(f"不支持的周期 {bar!r},可选:{list(BARS)}") + errors = [] + for name, fn in SOURCES: + try: + rows = fn(bar, limit) + if len(rows) >= 2: + return name, rows[-limit:] + errors.append(f"{name}: 数据不足({len(rows)})") + except Exception as e: # noqa: BLE001 —— 逐源兜底,任何异常都换下一个源 + errors.append(f"{name}: {e}") + raise RuntimeError(f"BTC {bar} K 线全部源失败:" + ";".join(errors)) + + +if __name__ == "__main__": + src, rows = get_candles("1H", 5) + print(src) + for r in rows: + print(r) diff --git a/config.py b/config.py index 8cd0688..8f4486f 100644 --- a/config.py +++ b/config.py @@ -32,6 +32,10 @@ _load_dotenv(os.path.join(BASE_DIR, ".env")) # 不再走 Zectrix 公网 API + 设备 MAC,改为直连局域网设备 mDNS host 的 HTTP 接口。 EPD_HOST = os.environ.get("EPD_HOST", "epd400a44.local") +# ---- 时区 ---- +# 服务端可能部署在境外 VPS,所有「现在时刻」与时间戳格式化统一按此时区(data.TZ),本机与服务端结果一致。 +TZ_NAME = os.environ.get("TZ_NAME", "Asia/Shanghai") + # ---- 画布(喵喵固件 400x300 三色屏)---- WIDTH, HEIGHT = 400, 300 @@ -40,6 +44,26 @@ WIDTH, HEIGHT = 400, 300 E1002_WIDTH, E1002_HEIGHT = 800, 480 E1002_COLOR_STRIP = os.environ.get("E1002_COLOR_STRIP", "0") == "1" # 底部六色测试色条(真机校准用,默认关) +# ---- 图片服务(server.py:恒定 URL,每次请求内存中现渲染 800x480 PNG,供 SenseCraft HTML 控件周期抓取)---- +# 服务端三路上游的内存缓存有效期(秒):收到请求先查缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染; +# 拉取失败沿用旧值且不更新时间戳(下次请求再试)。设 0 = 每次请求都实拉。天气/BTC 复用上面的 WEATHER_TTL/BTC_TTL。 +MAO_TTL = int(os.environ.get("MAO_TTL", "60")) # 橘喵今日实时,默认 60 秒 +# 真机验证结论:Image 控件只在填写 URL 时抓一次快照、此后不再更新;HTML 控件填同一图片 URL 则每周期重抓。 +SERVER_HOST = os.environ.get("SERVER_HOST", "0.0.0.0") +SERVER_PORT = int(os.environ.get("SERVER_PORT", "8790")) +IMAGE_PATH = os.environ.get("IMAGE_PATH", "/e1002.png") # 方案一:恒定图片路径(每次请求内存渲染 PNG);可改成难猜的路径防扫描 +HTML_PATH = os.environ.get("HTML_PATH", "/e1002.html") # 方案二:恒定网页路径(每次请求生成 800x480 现代网页,平台截图) +# 方案三:服务端用无头 Chrome 把方案二的网页截成 800x480,再按「最近色、不抖动」固化为六色 PNG(需服务器装 Chrome/Chromium) +HTML_PNG_PATH = os.environ.get("HTML_PNG_PATH", "/e1002-web.png") +CHROME_BIN = os.environ.get("CHROME_BIN", "") # 为空则按常见路径自动探测(macOS Google Chrome / google-chrome / chromium) +CHROME_EXTRA_ARGS = os.environ.get("CHROME_EXTRA_ARGS", "") # 例如 Linux 以 root 运行需 "--no-sandbox" +# 每通道二值化阈值(0~255):低于阈值归 0、否则归 255;取 160(而非 128)偏向保留墨迹,细笔画的浅灰边不至于被丢成白 +HTML_PNG_THRESHOLD = int(os.environ.get("HTML_PNG_THRESHOLD", "160")) +PUSH_TOKEN = os.environ.get("PUSH_TOKEN", "") # 推送鉴权令牌(X-Push-Token 头);为空则拒绝一切推送 +PUSH_STATE_PATH = os.environ.get("PUSH_STATE_PATH", "") # 推送数据落盘路径(重启后恢复);为空=仅内存 +# 本机推送端(main.py --target push):服务地址(含协议与端口,不含路径),令牌同 PUSH_TOKEN +PUSH_URL = os.environ.get("PUSH_URL", "") + # ---- 资源路径 ---- FONT_PATH = os.path.join(BASE_DIR, "fonts", "fusion-pixel-12px-proportional-zh_hans.otf") OUTPUT_DIR = os.path.join(BASE_DIR, "output") @@ -67,11 +91,19 @@ QWEATHER_KEY = os.environ.get("QWEATHER_KEY", "") WEATHER_LOCATION = os.environ.get("WEATHER_LOCATION", "118.840,31.953") WEATHER_LOC_NAME = "南京·江宁" # 天气缓存:业务数据每分钟刷新,但天气 15 分钟才重新拉一次(cron 每次独立进程,缓存须落盘)。 -WEATHER_TTL = int(os.environ.get("WEATHER_TTL", "900")) # 秒,默认 900=15 分钟 +WEATHER_TTL = int(os.environ.get("WEATHER_TTL", "900")) # 秒,默认 900=15 分钟(本机文件缓存与 server.py 内存缓存共用) WEATHER_CACHE_PATH = os.path.join(BASE_DIR, ".weather_cache.json") # 近30天趋势缓存:recent-days 每天只更新一次,按日期为 key 缓存,当天命中即不再请求。 TREND_CACHE_PATH = os.path.join(BASE_DIR, ".trend_cache.json") +# ---- 比特币 K 线(仅 800x480 宽版底部面板)---- +# 免费公开接口,无需密钥,逐源兜底(OKX → Coinbase → Huobi,见 btc_api.py)。当前为时 K、近 72 小时(3 天); +# 每 5 分钟刷新(与 launchd 节律一致,最新一根随实时价推进)。拉取失败沿用旧缓存。缓存文件已在 .gitignore。 +BTC_BAR = os.environ.get("BTC_BAR", "1H") # 周期:"1H" 时K / "1D" 日K +BTC_LIMIT = int(os.environ.get("BTC_LIMIT", "72")) # 展示最近 N 根(时 K 默认 72 = 近 3 天) +BTC_TTL = int(os.environ.get("BTC_TTL", "300")) # 秒,默认 5 分钟(本机文件缓存与 server.py 内存缓存共用) +BTC_CACHE_PATH = os.path.join(BASE_DIR, ".btc_cache.json") + # ---- Claude Code 用量(Claude Usage 模块)数据源 ---- # 只读本地、**不发任何网络请求**(详见 usage_local.py)——生产者/消费者模式: # 生产者 = 打过补丁的 claude-statusline,把每次从 Claude Code stdin 拿到的**权威实时**额度 diff --git a/data.py b/data.py index 3ced98d..218f088 100644 --- a/data.py +++ b/data.py @@ -4,18 +4,35 @@ 数据层 —— 汇总仪表盘所需的全部数据。 橘喵今日经营 + 近30天趋势来自 jm_api(jm-devops 后端统计接口,免鉴权 GET)。 -天气来自 weather_api(和风天气,定位南京·秦淮)。日期/时间用真实系统时间。 +天气来自 weather_api(和风天气,定位南京·江宁)。日期/时间用真实系统时间(统一按 config.TZ_NAME 时区)。 任何接口失败时相关数据以占位符 "--" 呈现,不回退 mock。 + +两种调用形态: + - 本机脚本(main.py):get_dashboard_data() —— 带文件缓存(天气/趋势/BTC),用量读本地快照; + - 图片服务(server.py):直接用各 `*_fetch()` 纯拉取函数 + `_usage(now, raw=<推送数据>)`,不落盘。 """ import json from datetime import datetime +from zoneinfo import ZoneInfo import config import jm_api import weather_api import usage_local +import btc_api WEEK_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] +TZ = ZoneInfo(config.TZ_NAME) + + +def now_tz(): + """当前时刻(带 config.TZ_NAME 时区)。""" + return datetime.now(TZ) + + +def _local(ts): + """epoch 秒 → 该时区的 datetime。""" + return datetime.fromtimestamp(ts, TZ) TREND_DAYS = 30 # 近 N 天趋势 FIVE_HOUR, SEVEN_DAY = 5 * 3600, 7 * 86400 # 两个滚动窗口长度(秒),用于算「时间已流逝%」 @@ -85,7 +102,7 @@ def _trend(now): def _weather(now): - """和风天气(南京·秦淮),带 WEATHER_TTL 文件缓存。 + """和风天气(南京·江宁),带 WEATHER_TTL 文件缓存(本机脚本用)。 缓存未过期 → 直接复用,不发请求(每分钟刷新时省掉 14/15 次天气调用)。 过期 → 重新拉取并写缓存;全部接口失败时沿用上次缓存,仍无缓存才回退占位 "--"。 @@ -94,6 +111,18 @@ def _weather(now): cached = _read_weather_cache() if cached and ts - cached.get("ts", 0) < config.WEATHER_TTL: return cached["data"] + out, ok = weather_fetch() + if ok: + _write_weather_cache(ts, out) + return out + if cached: # 全部失败:沿用上次缓存,避免天气区整片 "--" + print("[警告] 天气全部拉取失败,沿用上次缓存") + return cached["data"] + return out + + +def weather_fetch(): + """实时拉取天气(不读不写缓存)→ (weather dict,是否至少一个接口成功)。服务端每次请求直接调用。""" out = {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""} ok = False try: @@ -110,13 +139,7 @@ def _weather(now): ok = True except Exception as e: print(f"[警告] 今日温区拉取失败:{e}") - if ok: - _write_weather_cache(ts, out) - return out - if cached: # 全部失败:沿用上次缓存,避免天气区整片 "--" - print("[警告] 天气全部拉取失败,沿用上次缓存") - return cached["data"] - return out + return out, ok # ---------- 格式化 ---------- @@ -149,8 +172,11 @@ def _placeholder_col(k): # ---------- 橘喵业务数据 ---------- -def _mao(now): +def mao_fetch(now, with_trend=True): + """橘喵今日经营(实时接口);with_trend=False 时不拉近 30 天趋势(服务端与宽版都不用它,且趋势有文件缓存)。 + 返回(mao dict,实时接口是否成功)。""" upd = now.strftime("%H:%M") + ok = True # 今日经营(realtime) try: rt = jm_api.get_realtime() @@ -163,26 +189,74 @@ def _mao(now): except Exception as e: print(f"[警告] 今日经营拉取失败:{e}") cols = [_placeholder_col(k) for k in ("单量", "流水", "毛利")] - # 近30天趋势(按日期缓存,每天只拉一次);宽版另用其最后 7 天做「近7天」数据表 - trend = _trend(now) - return {"upd": upd, "cols": cols, "trend": trend, "week": _recent_days(trend, 7)} + ok = False + # 近30天趋势(按日期缓存,每天只拉一次) + trend = _trend(now) if with_trend else {"dates": [], "series": []} + return {"upd": upd, "cols": cols, "trend": trend}, ok -def _recent_days(trend, n): - """趋势的最后 n 天 → 表格数据 {dates:[MM/DD…], rows:[(名, [文本…], 合计文本)]},供宽版「橘喵·近7天」表使用。 - recent-days 不含今日,故表头日期到昨天为止。单量按千分位、流水/毛利按紧凑金额格式;无数据返回空 dict。""" - dates, series = trend.get("dates") or [], trend.get("series") or [] - if not dates or not series: - return {} - dates = dates[-n:] - rows = [] - for s in series: - data = (s.get("data") or [])[-n:] - if len(data) != len(dates): - continue - fmt = _fmt_count if s["k"] == "单量" else _fmt_money - rows.append((s["k"], [fmt(v) for v in data], fmt(sum(data)))) - return {"dates": dates, "rows": rows} +def _mao(now): + return mao_fetch(now)[0] + + +# ---------- 比特币 K 线 ---------- +def _read_btc_cache(): + try: + with open(config.BTC_CACHE_PATH, encoding="utf-8") as f: + return json.load(f) # {"ts": float, "data": {...}} + except Exception: + return None + + +def _write_btc_cache(ts, data): + try: + with open(config.BTC_CACHE_PATH, "w", encoding="utf-8") as f: + json.dump({"ts": ts, "data": data}, f, ensure_ascii=False) + except Exception as e: + print(f"[警告] BTC 缓存写入失败:{e}") + + +def _btc(now): + """比特币 K 线(宽版底部面板;周期 BTC_BAR、根数 BTC_LIMIT),带 BTC_TTL 文件缓存; + 拉取失败沿用旧缓存,仍无缓存回退空 candles。 + 结构契约(renderer 依赖):{symbol, bar, src, candles:[{t, o, h, l, c}], last, chg, chg_label},其中 t 为轴标签文本; + last 为最新一根(当前未收盘)的收盘价;chg 为涨跌幅(%)——时 K 取相对 24 根前(24h)收盘、不足则相对首根, + 日 K 取相对上一根收盘,chg_label 说明口径("24h"/"1d");涨=绿 / 跌=红 由 renderer 按 c≥o 判定。""" + ts = now.timestamp() + bar = config.BTC_BAR + cached = _read_btc_cache() + if cached and ts - cached.get("ts", 0) < config.BTC_TTL and cached.get("data", {}).get("bar") == bar: + return cached["data"] + try: + out = btc_fetch() + _write_btc_cache(ts, out) + return out + except Exception as e: + print(f"[警告] BTC K 线拉取失败:{e}") + if cached: + print("[警告] 沿用上次 BTC 缓存") + return cached["data"] + return btc_empty() + + +def btc_empty(): + return {"symbol": "BTC/USDT", "bar": config.BTC_BAR, "src": "", "candles": [], "last": None, "chg": None, "chg_label": ""} + + +def btc_fetch(): + """实时拉取 BTC K 线并整理成渲染结构(不读不写缓存);失败抛异常。服务端每次请求直接调用。""" + bar = config.BTC_BAR + src, rows = btc_api.get_candles(bar, config.BTC_LIMIT) + fmt = "%m/%d %H:%M" if bar == "1H" else "%m/%d" + candles = [{"t": _local(r["ts"]).strftime(fmt), "o": r["o"], "h": r["h"], "l": r["l"], "c": r["c"]} for r in rows] + last = rows[-1]["c"] + if bar == "1H": + base = rows[-25]["c"] if len(rows) >= 25 else rows[0]["c"] + chg_label = "24h" if len(rows) >= 25 else f"{len(rows) - 1}h" + else: + base, chg_label = rows[-2]["c"], "1d" + return {"symbol": "BTC/USDT", "bar": bar, "src": src, "candles": candles, + "last": last, "chg": (last - base) / base * 100 if base else 0.0, "chg_label": chg_label} # ---------- Claude Code 用量 ---------- @@ -214,7 +288,7 @@ def _fmt_reset(resets_at, window): ts = _parse_reset_ts(resets_at) if ts is None: return None - dt = datetime.fromtimestamp(ts) + dt = _local(ts) return dt.strftime("%H:%M") if window <= 86400 else dt.strftime("%m/%d %H:%M") @@ -227,26 +301,25 @@ USAGE_WINDOWS = ( ("seven_day", "7d", "all 7d", SEVEN_DAY, False), ("seven_day_fable", "Fable 7d", "fable 7d", SEVEN_DAY, True), ) +# ChatGPT 用量窗口:键名沿用同一套约定(five_hour / seven_day / seven_day_codex),推送端照此结构提供原始数据即可。 +GPT_WINDOWS = ( + ("five_hour", "5h", "all 5h", FIVE_HOUR, False), + ("seven_day", "7d", "all 7d", SEVEN_DAY, False), + ("seven_day_codex", "Codex 7d", "codex 7d", SEVEN_DAY, True), +) -def _usage(now): - """Claude Code 用量(5h / 7d / Fable 7d)——只读本地 statusline 生产的快照(见 usage_local.py),**不发任何请求**。 - - utilization 来自 statusline 从 Claude Code stdin 落盘的权威实时值(Fable 行来自其转存的 API limits); - time_pct(时间已流逝%) 与 pace 每次按当前时间**现算**(resets_at 为绝对时间)。 - 文件缺失/损坏 → 回退占位 pct=None(renderer 显示 "--");快照里没有某窗口(如旧版 statusline 无 Fable)同样 "--"。 +def usage_from_raw(now, raw, windows, title): + """原始快照 → 渲染结构(Claude / ChatGPT 通用)。 + raw 形如 {five_hour:{utilization, resets_at}, seven_day:{...}, :{...}|null, updated_at?}; + utilization 为已用百分比,resets_at 为 epoch 秒或 ISO;缺哪个窗口哪个显示 "--"。 + time_pct(时间已流逝%)与 pace 按传入的 now **现算**——所以推送端只需推原始快照,服务端渲染时 pace 仍随时钟准确推进。 结构契约(renderer 依赖):{title, warn, ok, pace_tol, updated?, bars:[{k, label, pct(0~100 或 None), time_pct?, reset?, scoped}]}; pct ≥ warn 时该条进度条与百分比转红(宽版另有 pct ≤ ok 绿档);pace = pct − time_pct(>0 超前↑ / <0 节余↓,宽版按 ±pace_tol 分三色); - reset 为重置时刻文本;updated 为快照落盘时刻 HH:MM(活跃使用时持续刷新,空闲时停在最后一次)。 - """ - raw = {} - try: - raw = usage_local.get_usage() - except Exception as e: - print(f"[警告] Claude 用量读取失败:{e}") - + reset 为重置时刻文本;updated 为快照落盘时刻 HH:MM(活跃使用时持续刷新,空闲时停在最后一次)。""" + raw = raw or {} bars = [] - for key, k, label, win, scoped in USAGE_WINDOWS: + for key, k, label, win, scoped in windows: seg = raw.get(key) or {} util = seg.get("utilization") pct = int(round(float(util))) if util is not None else None @@ -258,32 +331,47 @@ def _usage(now): if rs: bar["reset"] = rs bars.append(bar) - out = {"title": "Claude Usage", "warn": config.USAGE_WARN, "ok": config.USAGE_OK, "pace_tol": config.PACE_TOL, "bars": bars} + out = {"title": title, "warn": config.USAGE_WARN, "ok": config.USAGE_OK, "pace_tol": config.PACE_TOL, "bars": bars} upd = _parse_reset_ts(raw.get("updated_at")) if upd: - out["updated"] = datetime.fromtimestamp(upd).strftime("%H:%M") + out["updated"] = _local(upd).strftime("%H:%M") return out -def _usage_gpt(now): - """ChatGPT 用量——**目前为 mock 占位**(mock=True,renderer 在标题右侧标「示例数据」)。 - 结构与 _usage 完全一致,后续接入真实数据源时只需替换本函数、保持返回结构不变。 - mock 取值刻意覆盖宽版三档配色:5h 88%(≥warn 红)、7d 71%(黑)、Codex 7d 22%(≤ok 绿,scoped,与左侧 fable 行对称)。""" - bars = [] - for k, label, pct, win, remain, scoped in (("5h", "all 5h", 88, FIVE_HOUR, 2 * 3600, False), - ("7d", "all 7d", 71, SEVEN_DAY, 3 * 86400, False), - ("Codex 7d", "codex 7d", 22, SEVEN_DAY, 3 * 86400, True)): - reset_ts = now.timestamp() + remain - bars.append({"k": k, "label": label, "pct": pct, "scoped": scoped, - "time_pct": _window_time_pct(reset_ts, win, now), - "reset": _fmt_reset(reset_ts, win)}) - return {"title": "ChatGPT Usage", "warn": config.USAGE_WARN, "ok": config.USAGE_OK, "pace_tol": config.PACE_TOL, - "bars": bars, "mock": True} +def _usage(now, raw=None): + """Claude Code 用量(5h / 7d / Fable 7d)。raw 为 None 时读本地 statusline 快照(见 usage_local.py,**不发请求**); + 服务端传入推送来的原始快照。utilization 来自 statusline 从 Claude Code stdin 落盘的权威实时值(Fable 行来自其转存的 API limits)。""" + if raw is None: + raw = {} + try: + raw = usage_local.get_usage() + except Exception as e: + print(f"[警告] Claude 用量读取失败:{e}") + return usage_from_raw(now, raw, USAGE_WINDOWS, "Claude Usage") + + +def gpt_mock_raw(now): + """ChatGPT 原始快照的 mock(尚无真实数据源时用):刻意覆盖宽版三档配色—— + 5h 88%(≥warn 红)、7d 71%(黑)、Codex 7d 22%(≤ok 绿,scoped,与左侧 fable 行对称)。""" + t = now.timestamp() + return {"five_hour": {"utilization": 88, "resets_at": t + 2 * 3600}, + "seven_day": {"utilization": 71, "resets_at": t + 3 * 86400}, + "seven_day_codex": {"utilization": 22, "resets_at": t + 3 * 86400}} + + +def _usage_gpt(now, raw=None): + """ChatGPT 用量。raw 为推送来的原始快照(结构同 gpt_mock_raw);为 None 时用 mock 并标 mock=True + (renderer 在标题右侧标「示例数据」)。接入真实数据源时由推送端提供 raw,本函数无需改动。""" + mock = raw is None + out = usage_from_raw(now, gpt_mock_raw(now) if mock else raw, GPT_WINDOWS, "ChatGPT Usage") + if mock: + out["mock"] = True + return out def get_dashboard_data(now=None): - """返回渲染所需的完整数据字典。""" - now = now or datetime.now() + """返回渲染所需的完整数据字典(本机脚本形态:带文件缓存,用量读本地快照)。""" + now = now or now_tz() return { "date": { "greg": now.strftime("%Y/%m/%d"), @@ -294,4 +382,5 @@ def get_dashboard_data(now=None): "mao": _mao(now), "usage": _usage(now), "usage_gpt": _usage_gpt(now), + "btc": _btc(now), } diff --git a/html_renderer.py b/html_renderer.py new file mode 100644 index 0000000..36f0b39 --- /dev/null +++ b/html_renderer.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +方案二:800x480 的**现代网页**版仪表盘(server.py 的 HTML_PATH 每次请求现生成),供 SenseCraft HTML 控件用无头浏览器截图。 + +与 PNG 版(renderer_e1002)的差别: + - 不限点阵字体:系统中文字体栈(PingFang / Microsoft YaHei / Noto Sans SC)+ Google Fonts 的 Noto Sans SC 兜底 + (平台截图机若无中文字体会掉字,网络字体是保险;能否加载取决于其出网能力)。 + - 纯 HTML+CSS+内联 SVG,**不依赖 JavaScript**(平台是否执行 JS 未证实),数据全部服务端写死进 HTML。 + - 颜色只用六色纯值 #000/#fff/#f00/#0f0(黄/蓝未用),抗锯齿边缘交给平台抖动——这正是要和 PNG 版对比的点。 + - 版式与 PNG 版同构:顶部时钟/天气;左 Claude Usage、右 ChatGPT Usage;橘喵今日实时三列;BTC 时 K(SVG)。 + - 配色规则与 PNG 版一致:用量条 ≥warn 红 / ≤ok 绿 / 其间黑;pace ≥+tol 红 / ≤−tol 绿 / 其间黑;环比同比 涨红 跌黑;K 线 涨绿 跌红。 +""" +from html import escape as _e + +RED, GREEN, BLACK = "#f00", "#0f0", "#000" + +_CSS = """ +*{box-sizing:border-box;margin:0;padding:0} +html,body{width:800px;height:480px;overflow:hidden;background:#fff;color:#000; + font-family:"PingFang SC","Microsoft YaHei","Noto Sans SC","Helvetica Neue",Arial,sans-serif; + -webkit-font-smoothing:antialiased} +.page{width:800px;height:480px;padding:12px 16px 10px;display:flex;flex-direction:column;gap:6px} +.hd{display:flex;justify-content:space-between;align-items:flex-end;height:92px;border-bottom:2px solid #000;padding-bottom:6px} +.clock{font-size:72px;font-weight:800;line-height:1;letter-spacing:-2px;font-variant-numeric:tabular-nums} +.date{font-size:15px;margin-top:4px;color:#000} +.wx{text-align:right;display:flex;align-items:flex-end;gap:14px} +.wx .t{font-size:44px;font-weight:800;line-height:1} +.wx .c{font-size:15px;line-height:1.45} +.wx .loc{font-size:13px;color:#000} +.pin{display:inline-block;width:9px;height:9px;border-radius:50% 50% 50% 0;background:#f00;transform:rotate(-45deg);margin-right:4px;vertical-align:-1px} +.row{display:flex;gap:16px} +.card{flex:1;min-width:0} +.card h3{font-size:14px;font-weight:700;display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px} +.card h3 em{font-style:normal;font-weight:400;font-size:12px} +.card h3 b::before{content:"";display:inline-block;width:9px;height:9px;background:#000;margin-right:6px;vertical-align:-1px} +.u{display:grid;grid-template-columns:64px 1fr 58px 44px;align-items:center;column-gap:8px;height:26px} +.u .k{font-size:13px;font-weight:700;font-family:"SF Mono",Menlo,Consolas,monospace} +.bar{height:11px;border:1.5px solid #000;position:relative;background:#fff} +.bar i{position:absolute;left:0;top:0;bottom:0;background:#000} +.u .p{font-size:19px;font-weight:800;text-align:right;font-variant-numeric:tabular-nums;line-height:1} +.u .pace{font-size:13px;font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap} +.tri{display:inline-block;width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;vertical-align:1px;margin-right:2px} +.tri.up{border-bottom:8px solid currentColor}.tri.dn{border-top:8px solid currentColor} +.rt{border-top:2px solid #000;border-bottom:2px solid #000;padding:6px 0 4px} +.rt .cols{display:flex} +.rt .col{flex:1;text-align:center;border-left:1px dashed #000;padding:2px 0} +.rt .col:first-child{border-left:0} +.rt .name{font-size:13px;margin-bottom:2px} +.rt .grp{display:inline-flex;align-items:center;gap:12px} +.rt .v{font-size:36px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums} +.rt .cmp{text-align:left;font-size:12px;line-height:1.5;font-variant-numeric:tabular-nums} +.btc h3{margin-bottom:2px} +.btc svg{display:block} +""" + + +def _tier_bar(bar, usage): + """用量条颜色三档(与 renderer_e1002 一致)。返回 (填充/边框色, 百分比文字色)。""" + pct = bar.get("pct") + if not isinstance(pct, (int, float)): + return BLACK, BLACK + if pct >= usage.get("warn", 80): + return RED, RED + if pct <= usage.get("ok", 30): + return GREEN, BLACK + return BLACK, BLACK + + +def _tier_pace(diff, usage): + tol = usage.get("pace_tol", 10) + return RED if diff >= tol else GREEN if diff <= -tol else BLACK + + +def _usage_card(usage): + note = "示例数据" if usage.get("mock") else (f'快照 {usage["updated"]}' if usage.get("updated") else "") + rows = [] + for b in usage.get("bars", []): + pct = b.get("pct") + ok = isinstance(pct, (int, float)) + fill, ptxt = _tier_bar(b, usage) + width = max(0, min(100, int(pct))) if ok else 0 + pace = "" + if ok and "time_pct" in b: + diff = int(pct) - int(b["time_pct"]) + col = _tier_pace(diff, usage) + tri = '' if diff > 0 else ('' if diff < 0 else "") + pace = f'{tri}{abs(diff):02d}' + else: + pace = '' + rows.append( + f'
{_e(b.get("label") or b["k"])}' + f'' + f'{f"{int(pct):02d}%" if ok else "--"}{pace}
') + return (f'

{_e(usage.get("title", "Usage"))}{_e(note)}

' + + "".join(rows) + "
") + + +def _cmp(label, pair): + direction, val = pair + col = RED if direction == "up" else BLACK + tri = '' if direction == "up" else ('' if direction == "down" else "") + return f'
{_e(label)} {tri}{_e(val)}
' + + +def _realtime(mao): + cols = "".join( + f'
{_e(c["k"])}
{_e(c["v"])}' + f'{_cmp("环比", c["hb"])}{_cmp("同比", c["tb"])}
' + for c in mao["cols"]) + return (f'

橘喵·今日实时上次更新 {_e(mao["upd"])}

' + f'
{cols}
') + + +def _fmt_price(v): + return f"{v:,.0f}" if abs(v) >= 1000 else f"{v:.2f}" + + +def _btc(btc, W=768, H=118): + """BTC K 线 → 内联 SVG(涨绿跌红),坐标逻辑同 renderer_e1002._btc_chart。""" + btc = btc or {} + candles = btc.get("candles") or [] + kind = "时K" if btc.get("bar", "1H") == "1H" else "日K" + head = f'{_e(btc.get("symbol") or "BTC/USDT")}·{kind}' + right = "" + if candles and btc.get("last") is not None: + chg = btc.get("chg") or 0.0 + col = GREEN if chg >= 0 else RED + tri = '' if chg >= 0 else '' + right = (f'{_fmt_price(btc["last"])}  {_e(btc.get("chg_label") or "")}' + f' {tri}{abs(chg):.2f}%') + if not candles: + return f'

{head}

--
' + LBL, TOP, BOT = 66, 6, 16 + px0, px1 = 0, W - LBL + py0, py1 = TOP, H - BOT + hi_raw, lo_raw = max(c["h"] for c in candles), min(c["l"] for c in candles) + pad = (hi_raw - lo_raw or 1) * 0.04 + lo, hi = lo_raw - pad, hi_raw + pad + Y = lambda v: py1 - (v - lo) / (hi - lo) * (py1 - py0) + n = len(candles) + slot = (px1 - px0) / n + bw = max(3, int(slot * 0.6)) | 1 + parts = [f'', + f''] + for i, c in enumerate(candles): + cx = round(px0 + slot * (i + 0.5)) + col = GREEN if c["c"] >= c["o"] else RED + top, bot = sorted((round(Y(c["o"])), round(Y(c["c"])))) + parts.append(f'') + parts.append(f'') + last = candles[-1]["c"] + ly = round(Y(last)) + parts.append(f'') + hi_y, lo_y = py0 + 5, py1 - 1 + ty = min(max(ly + 4, hi_y + 13), lo_y - 13) + txt = 'font-size="12" font-family="SF Mono,Menlo,Consolas,monospace" text-anchor="end"' + parts.append(f'{_fmt_price(hi_raw)}') + parts.append(f'{_fmt_price(lo_raw)}') + parts.append(f'{_fmt_price(last)}') + lab = lambda c: _e(c.get("t") or c.get("d") or "") + ax = 'font-size="11" font-family="SF Mono,Menlo,Consolas,monospace"' + parts.append(f'{lab(candles[0])}') + parts.append(f'{lab(candles[n // 2])}') + parts.append(f'{lab(candles[-1])}') + parts.append("") + return f'

{head}{right}

{"".join(parts)}
' + + +def render_html(d): + """data dict(同 renderer_e1002.render 的输入)→ 完整 HTML 字符串。""" + dt, wt = d["date"], d["weather"] + head = ( + f'
{_e(dt["time"])}
' + f'
{_e(dt["week"])}  {_e(dt["greg"])}
' + f'
{_e(wt["loc"])}
' + f'{_e(wt["cond"])}
{_e(wt["range"])}
{_e(wt["temp"])}
') + body = (head + + f'
{_usage_card(d["usage"])}{_usage_card(d.get("usage_gpt") or {"title": "ChatGPT Usage"})}
' + + _realtime(d["mao"]) + + _btc(d.get("btc"))) + return ("" + "" + "" + "" + "" + f"eink dashboard
{body}
") diff --git a/html_shot.py b/html_shot.py new file mode 100644 index 0000000..c2ea82f --- /dev/null +++ b/html_shot.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +方案三:把 html_renderer 生成的网页用**服务端无头 Chrome** 截成 800x480,再**固化为六色 PNG**(最近色、不抖动)。 + +为什么需要它:SenseCraft 抓 HTML 时是平台自己截图再量化,文字抗锯齿的灰边会被它抖动成噪点、细笔画被打断(真机实拍已确认)。 +在我们这一侧先截图、先量化,交给平台的就是纯六色图,平台无从抖动——效果对齐 PNG 方案,同时保留现代字体与网页排版。 + +量化策略(不抖动): + 1. 每通道二值化:v < HTML_PNG_THRESHOLD → 0,否则 → 255。阈值默认 160(偏向保墨迹):抗锯齿的中灰边缘归黑, + 细笔画不会因为「不够黑」而消失;纯红/绿/黄/蓝/黑/白本身各通道就是 0/255,不受影响。 + 2. 二值化后像素落在 {0,255}^3 的 8 个角,其中青/品/其它非六色角极少见(只可能出现在彩色抗锯齿边缘), + 再用 Pillow 的调色板量化(dither=NONE)映射到最近的六色。 + → 输出保证只含 SPECTRA6 六色。 + +无头 Chrome 只能把截图写到文件,这里用临时文件并立刻删除(Linux 下 /tmp 通常是 tmpfs,不落磁盘); +若想彻底零文件需改用 Playwright/CDP,本项目不引第三方依赖,先不做。 +""" +import os +import shutil +import subprocess +import tempfile +import threading +from PIL import Image + +import config +from renderer_e1002 import SPECTRA6 + +_CANDIDATES = ( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome", +) + + +def find_chrome(): + if config.CHROME_BIN: + return config.CHROME_BIN + for c in _CANDIDATES: + if os.path.isabs(c): + if os.path.exists(c): + return c + elif shutil.which(c): + return shutil.which(c) + raise RuntimeError("未找到 Chrome/Chromium,请配置 CHROME_BIN") + + +_SHOT_LOCK = threading.Lock() # 无头 Chrome 共用默认 profile,截图串行化避免互相干扰 + + +def screenshot_html(html: str, size=(800, 480), timeout=40) -> Image.Image: + """HTML 字符串 → 无头 Chrome 截图(RGB Image)。临时文件用后即删。 + 注意不要传 --user-data-dir 指向全新目录:新版 --headless=new 对空 profile 的首启流程会卡死到超时(实测)。""" + chrome = find_chrome() + tmpdir = tempfile.mkdtemp(prefix="eink-shot-") + html_path = os.path.join(tmpdir, "page.html") + png_path = os.path.join(tmpdir, "shot.png") + try: + with open(html_path, "w", encoding="utf-8") as f: + f.write(html) + cmd = [chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--force-device-scale-factor=1", + f"--window-size={size[0]},{size[1]}", "--virtual-time-budget=6000", f"--screenshot={png_path}"] + if config.CHROME_EXTRA_ARGS: + cmd += config.CHROME_EXTRA_ARGS.split() + cmd.append("file://" + html_path) + with _SHOT_LOCK: + subprocess.run(cmd, check=True, timeout=timeout, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + with Image.open(png_path) as im: + return im.convert("RGB").copy() + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +def quantize6(img: Image.Image, threshold=None) -> Image.Image: + """RGB → 六色纯色(最近色、不抖动)。见模块文档。""" + t = config.HTML_PNG_THRESHOLD if threshold is None else threshold + lut = [0 if v < t else 255 for v in range(256)] * 3 + bin_img = img.convert("RGB").point(lut) + pal = Image.new("P", (1, 1)) + flat = [c for rgb in SPECTRA6 for c in rgb] + pal.putpalette(flat + [0] * (768 - len(flat))) + q = bin_img.quantize(palette=pal, dither=Image.Dither.NONE) + return q.convert("RGB") + + +def html_to_png6(html: str) -> Image.Image: + return quantize6(screenshot_html(html)) + + +if __name__ == "__main__": + import sys, data, html_renderer + d = data.get_dashboard_data() + im = html_to_png6(html_renderer.render_html(d)) + out = sys.argv[1] if len(sys.argv) > 1 else "output/e1002/web6.png" + os.makedirs(os.path.dirname(out), exist_ok=True) + im.save(out) + print(out, im.size, sorted(c for _, c in im.getcolors(64))) diff --git a/main.py b/main.py index a2ac943..9746e76 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,9 @@ python3 main.py # 渲染并推送(设备 host 取自 .env 的 EPD_HOST) python3 main.py --render-only # 只渲染生成图片,不推送(output/dashboard.png) python3 main.py --target e1002 # reTerminal E1002:渲染 800x480 六色图 + 固定尺寸 index.html 到 output/e1002/, - # 不推送(由 SenseCraft HMI 的 HTML 控件从公网地址周期性抓取) + # 不推送(静态部署形态;动态形态见 server.py) + python3 main.py --target push # 把本机 Claude 用量快照(~/.claude/usage-snapshot.json)推送到 server.py + # (PUSH_URL + PUSH_TOKEN),服务端渲染时按当前时刻现算 pace 配置统一从同目录 .env 读取(见 .env.example),真实环境变量优先。 """ @@ -21,10 +23,16 @@ from renderer import DashboardRenderer def main(): parser = argparse.ArgumentParser(description="墨水屏仪表盘:渲染并推送") parser.add_argument("--render-only", action="store_true", help="只渲染,不推送") - parser.add_argument("--target", choices=("miao", "e1002"), default="miao", - help="miao=喵喵固件 400x300 三色屏(默认,局域网推送);e1002=reTerminal E1002 800x480 六色(产出 HTML 页)") + parser.add_argument("--target", choices=("miao", "e1002", "push"), default="miao", + help="miao=喵喵固件 400x300 三色屏(默认,局域网推送);e1002=reTerminal E1002 800x480 六色(产出静态 HTML 页);" + "push=把本机用量快照推送到 server.py") args = parser.parse_args() + if args.target == "push": + from push_client import push_usage + push_usage() + return + data = get_dashboard_data() if args.target == "e1002": diff --git a/probe_server.py b/probe_server.py new file mode 100644 index 0000000..bb5c0db --- /dev/null +++ b/probe_server.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +验证工具 —— SenseCraft HMI 的 Image/HTML 控件对**同一 URL** 是否每个刷新周期都重新抓取。 + +原理:本地起一个极简 HTTP 服务,固定路径 /probe.png 每 --every 秒重绘一张 800x480 探针图 +(大号生成时刻 HH:MM:SS + 递增序号 + 六色校准块),响应头 Cache-Control: no-store; +每次有人请求就在终端打一行日志(时间、路径、User-Agent、来源 IP,含经隧道透传的 CF-Connecting-IP)。 + +判定: + - 终端日志每个设备刷新周期都出现一次 /probe.png 请求,且设备画面上的时刻/序号随之推进 → **每周期重抓,方案二成立**; + 此后只要不断覆盖同一 URL 指向的文件即可,无需再经 HTML 页。 + - 日志只在「部署」那一刻出现一次、之后再无请求 → 平台只在部署时抓一次并缓存,方案二不成立。 + - 也可同时把 /index.html(HTML 控件方案)放到另一台画布对照。 + +用法: + python3 probe_server.py [--port 8787] [--every 60] + # 另开终端暴露成公网 HTTPS(云端抓不到局域网/明文 HTTP)。cloudflared 快速隧道无需账号: + brew install cloudflared + cloudflared tunnel --url http://localhost:8787 + # 把打印出的 https://xxxx.trycloudflare.com/probe.png 填入 SenseCraft 画布的 Image 控件(铺满 800x480)→ 部署。 +""" +import argparse +import io +import threading +import time +from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +from PIL import Image, ImageDraw, ImageFont + +import config + +BLACK, WHITE, RED = (0, 0, 0), (255, 255, 255), (255, 0, 0) +SIX = [BLACK, WHITE, (255, 255, 0), RED, (0, 255, 0), (0, 0, 255)] + +_state = {"png": b"", "seq": 0, "stamp": ""} +_lock = threading.Lock() + + +def render_probe(seq, now): + """800x480 探针图:序号(左上)/ 大号时刻(居中,F96)/ 日期(下)/ 底部六色块。全部纯色。""" + img = Image.new("RGB", (800, 480), WHITE) + d = ImageDraw.Draw(img) + d.fontmode = "1" + f24 = ImageFont.truetype(config.FONT_PATH, 24) + f96 = ImageFont.truetype(config.FONT_PATH, 96) + d.text((20, 16), f"probe #{seq:04d}", font=f24, fill=BLACK) + d.text((20, 48), "SenseCraft re-fetch test / 同一 URL 每周期是否重抓", font=f24, fill=BLACK) + t = now.strftime("%H:%M:%S") + w = d.textlength(t, font=f96) + for dx in range(3): # 伪粗体 + d.text(((800 - w) / 2 + dx, 150), t, font=f96, fill=RED) + ds = now.strftime("%Y/%m/%d %A") + d.text(((800 - d.textlength(ds, font=f24)) / 2, 290), ds, font=f24, fill=BLACK) + d.text((20, 400), "画面上的时刻若随设备刷新周期推进 → 平台每周期重新抓取了同一 URL", font=f24, fill=BLACK) + n = len(SIX) + for i, c in enumerate(SIX): + d.rectangle([round(800 / n * i), 456, round(800 / n * (i + 1)) - 1, 479], fill=c) + buf = io.BytesIO() + img.save(buf, "PNG") + return buf.getvalue() + + +def regenerate(every): + while True: + now = datetime.now() + with _lock: + _state["seq"] += 1 + _state["png"] = render_probe(_state["seq"], now) + _state["stamp"] = now.strftime("%H:%M:%S") + print(f"[{now:%H:%M:%S}] 重绘 probe.png seq={_state['seq']}", flush=True) + time.sleep(every) + + +_INDEX = """ +probe + +""" + + +class Handler(BaseHTTPRequestHandler): + server_version = "probe/1.0" + + def log_message(self, fmt, *args): # 关闭默认日志,用自定义格式 + pass + + def _log(self, code): + ua = self.headers.get("User-Agent", "-") + ip = self.headers.get("CF-Connecting-IP") or self.headers.get("X-Forwarded-For") or self.client_address[0] + print(f"[{datetime.now():%H:%M:%S}] {code} GET {self.path} ip={ip} ua={ua}", flush=True) + + def _send(self, code, ctype, body): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = self.path.split("?", 1)[0] + if path == "/probe.png": + with _lock: + body = _state["png"] + self._log(200) + return self._send(200, "image/png", body) + if path in ("/", "/index.html"): + self._log(200) + return self._send(200, "text/html; charset=utf-8", _INDEX.encode("utf-8")) + if path == "/favicon.ico": + return self._send(204, "image/x-icon", b"") + self._log(404) + self._send(404, "text/plain; charset=utf-8", b"not found") + + do_HEAD = do_GET + + +def main(): + ap = argparse.ArgumentParser(description="SenseCraft 同 URL 重抓验证服务") + ap.add_argument("--port", type=int, default=8787) + ap.add_argument("--every", type=int, default=60, help="探针图重绘间隔(秒)") + args = ap.parse_args() + threading.Thread(target=regenerate, args=(args.every,), daemon=True).start() + while not _state["png"]: + time.sleep(0.05) + srv = ThreadingHTTPServer(("0.0.0.0", args.port), Handler) + print(f"探针服务已启动:http://localhost:{args.port}/probe.png (HTML 对照页:/index.html)") + print("下一步:另开终端执行 cloudflared tunnel --url http://localhost:%d ,把得到的 https 地址 + /probe.png 填入 Image 控件。" % args.port) + print("观察本终端:每个设备刷新周期是否出现一条 GET /probe.png。Ctrl+C 退出。", flush=True) + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() diff --git a/push_client.py b/push_client.py new file mode 100644 index 0000000..d897ea8 --- /dev/null +++ b/push_client.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +推送端(本机运行)—— 把本机的用量原始快照 POST 到 server.py,服务端暂存、渲染时现算 pace。 + +推什么: + - Claude:~/.claude/usage-snapshot.json 原文(statusline 生产,含 five_hour / seven_day / seven_day_fable / updated_at)→ POST /push/usage + - ChatGPT:暂无本机数据源;将来有了按 data.GPT_WINDOWS 的键(five_hour / seven_day / seven_day_codex,各含 utilization + resets_at) + 组一个 dict → POST /push/usage_gpt。目前不推,服务端用 mock 并标「示例数据」。 +鉴权:请求头 X-Push-Token = PUSH_TOKEN。网络:直连(不走代理),连接类异常重试 2 次。 +调度:与喵喵设备推送同节律,由 launchd 每 5 分钟跑一次 `main.py --target push`(或加进 run.sh)。 +""" +import json +import time +import urllib.error +import urllib.request + +import config +import usage_local + +RETRY_TIMES, RETRY_DELAY, TIMEOUT = 2, 2, 10 + + +def _post_json(url, payload): + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(url, data=body, method="POST", headers={ + "Content-Type": "application/json; charset=utf-8", + "X-Push-Token": config.PUSH_TOKEN, + "User-Agent": "eink-push/push_client", + }) + last = None + for i in range(RETRY_TIMES + 1): + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: # 4xx/5xx 不重试,直接报出服务端的说明 + raise RuntimeError(f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:200]}") from e + except urllib.error.URLError as e: # 连接类:重试 + last = e + if i < RETRY_TIMES: + time.sleep(RETRY_DELAY) + raise RuntimeError(f"连接失败:{last}") + + +def push_usage(): + if not config.PUSH_URL or not config.PUSH_TOKEN: + raise SystemExit("[推送] 需在 .env 配置 PUSH_URL 与 PUSH_TOKEN") + raw = usage_local.get_usage() # 缺文件直接抛错——没有快照就没什么可推 + resp = _post_json(config.PUSH_URL.rstrip("/") + "/push/usage", raw) + print(f"[推送] Claude 用量快照已推送到 {config.PUSH_URL}:{resp}") + return resp + + +if __name__ == "__main__": + push_usage() diff --git a/renderer_e1002.py b/renderer_e1002.py index 726ba25..e7bc1e2 100644 --- a/renderer_e1002.py +++ b/renderer_e1002.py @@ -5,18 +5,19 @@ reTerminal E1002 渲染层 —— 复用 renderer.DashboardRenderer 的组件( 利用 800x480 的四倍面积重新排版为「AI 用量看板」,输出 **E Ink Spectra 6 原生六色**(黑/白/黄/红/绿/蓝)纯色图, 并生成一张固定 800x480 的 HTML 页面供 SenseCraft HMI 的 HTML 控件云端截图拉取。 -版式(k=1,12/24/48px 点阵字体,不放大;四段,三道横规): +版式(k=1,点阵字体且**只用整数倍字号 12/24/36/48**,不放大;四段,三道横规): A 顶部(0~90) :大时钟+日期(左)|天气(右,贴 790)——与三色版同一套组件与尺寸。 B 用量(96~206) :左半 **Claude Usage**(all 5h / all 7d / fable 7d)|竖规 x=400|右半 **ChatGPT Usage**(mock:all 5h / all 7d / codex 7d)。 行组件沿用三色版的紧凑单行 `_usage_row`(行距 31),标签用 bar["label"](statusline 同款写法),列宽按最长标签自适应。 - C 今日(218~304):**橘喵·今日实时** 三列全宽:指标名 F12 作左上角角标;「值 F36 粗 + 右侧叠放的环比/同比」整组居中。 - D 近7天(328~438):**橘喵·近7天** 数据表(真实数据:30 天趋势缓存的最后 7 天;不画折线)—— - 列 = 指标名 + 7 个日期 + 合计,行 = 单量 / 流水 / 毛利;合计列伪粗体。 + C 今日(218~304):**橘喵·今日实时** 三列全宽:指标名 F12 列顶居中;「值 F36 粗 + 右侧叠放的环比/同比」整组居中。 + D 行情(~322~470):**BTC/USDT 时K(近 72 小时)** 蜡烛图(`_btc_chart`,数据来自 btc_api:OKX 主源、Coinbase/Huobi 备源,5 分钟缓存; + 周期/根数由 BTC_BAR/BTC_LIMIT 配置)——标题右侧最新价 + 24h 涨跌幅;**涨(收≥开)绿、跌红**,实心蜡烛 + 1px 影线; + 右侧最高/最低/最新价标签,最新价一道点规;底部首/中/末时间。 可选(470~479):六色测试色条(config.E1002_COLOR_STRIP,默认关;真机校准色彩量化时打开)。 六色的语义分工(每种颜色只有一个含义,不为「有」而用): - 黑/白:结构与正文。红:危险(用量 ≥ warn 的进度条+边框+百分比、pace ≥ +tol)、涨、定位、太阳/闪电。 - - 绿:健康(用量 ≤ ok 的进度条+边框、pace ≤ −tol)。用量与 pace 各自三档:绿 / 黑 / 红(阈值 USAGE_OK / USAGE_WARN / PACE_TOL)。 + - 绿:健康(用量 ≤ ok 的进度条+边框、pace ≤ −tol)、K 线上涨;红另用于 K 线下跌。用量与 pace 各自三档:绿 / 黑 / 红(阈值 USAGE_OK / USAGE_WARN / PACE_TOL)。 - 蓝:天气雨滴/雪花。黄:不用(用户明确不要黄色进度条;曾试过 60%~80% 黄「注意」档,已移除)。 - 整图只允许六种纯色;渲染后可用 `img.getcolors()` 自查。 @@ -42,6 +43,11 @@ class E1002Renderer(DashboardRenderer): def __init__(self, font_path=config.FONT_PATH): super().__init__(font_path, size=(config.E1002_WIDTH, config.E1002_HEIGHT), k=1) + # 宽版只用点阵字体的**整数倍字号**(12 / 24 / 36 / 48):基类给用量行用的 F13(标签、pace)与 F16(百分比) + # 是非整数倍缩放(笔画不均),这里分别替换成 12px 与 24px;行几何靠 _mid 按墨迹中心对齐,不受字号变化影响。 + from PIL import ImageFont + self.F13 = ImageFont.truetype(font_path, 12) + self.F16 = ImageFont.truetype(font_path, 24) # ---------- 配色钩子(三档:绿 健康 / 黑 正常 / 红 危险)---------- _ok, _pace_tol = _cfg.USAGE_OK, _cfg.PACE_TOL # 由 _usage_block 按数据覆盖 @@ -91,12 +97,12 @@ class E1002Renderer(DashboardRenderer): for b in bars: self._usage_row(x0, x1, y, b, warn, label_w=label_w) y += 31 - return y - 31 + 20 + return y - 31 + 18 # 末行进度条底部(cy=y+10,条高 15 → 底 y+17) def _realtime(self, y0, mao, x0=8, x1=None): - """橘喵·今日实时三列(宽版):指标名 F12 贴在每列左上角作角标; - 「值 F36 粗 + 右侧上下叠放的 环比/同比(F12)」整组在列内水平居中、竖直略偏下(给角标让位)。 - (曾试过名在值上方居中——小字压大数字、上半显空;也试过名与值同行——名参与居中会把整组推偏。)返回区块底部 y。""" + """橘喵·今日实时三列(宽版):指标名 F12 在每列顶部、列内水平居中作小标题; + 「值 F36 粗 + 右侧上下叠放的 环比/同比(F12)」整组在列内水平居中、竖直略偏下(给小标题让位)。 + (曾试过名与值同行——名参与居中会把整组推偏;也试过名贴左上角——用户要求改回居中。)返回区块底部 y。""" x1 = self.W - 8 if x1 is None else x1 self._label(y0, "橘喵·今日实时", right=f'上次更新 {mao["upd"]}', x0=x0 + 2, x1=x1 - 2) cols = mao["cols"]; n = len(cols) @@ -107,7 +113,7 @@ class E1002Renderer(DashboardRenderer): for i, c in enumerate(cols): cx0 = x0 + cw * i cx = cx0 + cw / 2 - self.text(cx0 + 12, TAG_Y, c["k"], self.F12) # 左上角角标 + self.ctext(cx, TAG_Y, c["k"], self.F12) # 列顶小标题,水平居中 vw = self._tw(c["v"], self.F36) + 1 cmpw = max(self._cmp_w("环比", c["hb"]), self._cmp_w("同比", c["tb"])) gx = cx - (vw + GAP + cmpw) / 2 # 整组左端(不含角标) @@ -117,34 +123,68 @@ class E1002Renderer(DashboardRenderer): self._cmp_at(gx + vw + GAP, CY + 9 - (t + b) / 2, "同比", c["tb"]) return y0 + 86 - def _week_table(self, y0, week, x0=10, x1=None): - """橘喵·近7天数据表:表头 = 指标名列 + 7 个日期 + 合计;行 = 单量/流水/毛利。 - 全部 F12、右对齐数字;合计列伪粗体;表头下一道点规。无数据居中 "--"。返回底部 y。""" + @staticmethod + def _fmt_price(v): + """价格文本:≥1000 千分位取整,否则两位小数。""" + return f"{v:,.0f}" if abs(v) >= 1000 else f"{v:.2f}" + + def _btc_chart(self, y0, y1, btc, x0=10, x1=None): + """BTC K 线蜡烛图(y0 标题行顶 ~ y1 底;周期由 btc["bar"] 决定标题「时K/日K」): + 标题右侧 最新价(F12 粗)+ 涨跌口径(24h/1d)+ 涨跌三角 + 涨跌幅(F12),涨绿 / 跌红; + 绘图区:每根蜡烛 = 1px 影线(最高↔最低)+ 实心实体(开↔收,宽≈槽位 60%、奇数像素居中),收≥开 绿、否则 红; + 右侧价格标签列:最高(顶)、最低(底)、最新(粗,随最新价位置浮动、避让顶底标签),最新价横贯一道点规; + 底部基线点规 + 首/中/末三个日期。无数据时居中 "--"。""" x1 = self.W - 10 if x1 is None else x1 - self._label(y0, "橘喵·近7天", right="不含今日", x0=x0, x1=x1) - dates, rows = (week or {}).get("dates") or [], (week or {}).get("rows") or [] - if not dates or not rows: - self.ctext((x0 + x1) / 2, y0 + 40, "--", self.F24) - return y0 + 80 - NAME_W, ROW = 44, 24 - ncol = len(dates) + 1 # 日期列 + 合计列 - cw = (x1 - x0 - NAME_W) / ncol - col_r = lambda i: x0 + NAME_W + cw * (i + 1) - 4 # 第 i 列右端(含右内边距) - hy = y0 + 24 - for i, d in enumerate(dates): - self.rtext(col_r(i), hy, d, self.F12) - self.rtext(col_r(len(dates)), hy, "合计", self.F12) - self.hdash(x0, x1, hy + 17, dash=2, gap=3) - y = hy + ROW - # 合计列前一道竖点规,把「逐日」与「合计」分开 - self.vdash(x0 + NAME_W + cw * len(dates), hy + 2, hy + ROW * (len(rows) + 1) - 6, dash=2, gap=3) - for name, vals, total in rows: - self.text(x0, y, name, self.F12) - for i, v in enumerate(vals): - self.rtext(col_r(i), y, v, self.F12) - self.rtext(col_r(len(dates)), y, total, self.F12, bold=1) - y += ROW - return y - ROW + 14 + btc = btc or {} + candles = btc.get("candles") or [] + kind = "时K" if btc.get("bar", "1H") == "1H" else "日K" + self._label(y0, f'{btc.get("symbol") or "BTC/USDT"}·{kind}', x0=x0, x1=x1) + if candles and btc.get("last") is not None: + chg = btc.get("chg") or 0.0 + col = GREEN if chg >= 0 else RED + xr = x1 + pct_txt = f"{abs(chg):.2f}%" + self.rtext(xr, y0, pct_txt, self.F12, fill=col); xr -= self._tw(pct_txt, self.F12) + 4 + self.tri(xr - 4, y0 + 8.5, 7, up=chg >= 0, fill=col); xr -= 8 + 6 + if btc.get("chg_label"): # 涨跌幅口径(24h / 1d) + self.rtext(xr, y0, btc["chg_label"], self.F12); xr -= self._tw(btc["chg_label"], self.F12) + 10 + self.rtext(xr, y0, self._fmt_price(btc["last"]), self.F12, bold=1) + if not candles: + self.ctext((x0 + x1) / 2, (y0 + y1) / 2 - 12, "--", self.F24) + return + LBL_W = 66 # 右侧价格标签列 + px0, px1 = x0, x1 - LBL_W + py0, py1 = y0 + 26, y1 - 16 # 底部留 16px 给日期 + hi_raw, lo_raw = max(c["h"] for c in candles), min(c["l"] for c in candles) + pad = (hi_raw - lo_raw or 1) * 0.04 + lo, hi = lo_raw - pad, hi_raw + pad + span = hi - lo + Y = lambda v: py1 - (v - lo) / span * (py1 - py0) + n = len(candles) + slot = (px1 - px0) / n + bw = max(3, int(slot * 0.6)) | 1 # 实体宽度取奇数,影线居中 + self.hdash(px0, px1, py1, dash=2, gap=3) # 基线 + for i, c in enumerate(candles): + cx = round(px0 + slot * (i + 0.5)) + col = GREEN if c["c"] >= c["o"] else RED + self.d.line([cx, round(Y(c["h"])), cx, round(Y(c["l"]))], fill=col, width=1) + top, bot = sorted((round(Y(c["o"])), round(Y(c["c"])))) + self.d.rectangle([cx - bw // 2, top, cx + bw // 2, bot], fill=col) # 十字星时 top==bot 退化为 1px 横线 + # ---- 右侧价格标签:最高 / 最低 / 最新 ---- + hi_y, lo_y = py0 - 6, py1 - 12 + self.rtext(x1, hi_y, self._fmt_price(hi_raw), self.F12) + self.rtext(x1, lo_y, self._fmt_price(lo_raw), self.F12) + last = candles[-1]["c"] + ly = round(Y(last)) + self.hdash(px0, px1, ly, dash=1, gap=3) # 最新价水平点规 + ty = min(max(ly - 8, hi_y + 15), lo_y - 15) # 避让顶/底标签 + self.rtext(x1, ty, self._fmt_price(last), self.F12, bold=1) + # ---- X 轴日期:首 / 中 / 末 ---- + xl_y = py1 + 3 + lab = lambda c: c.get("t") or c.get("d") or "" + self.text(px0, xl_y, lab(candles[0]), self.F12) + self.ctext((px0 + px1) / 2, xl_y, lab(candles[n // 2]), self.F12) + self.rtext(px1, xl_y, lab(candles[-1]), self.F12) # ---------- 入口 ---------- def render(self, data): @@ -179,8 +219,8 @@ class E1002Renderer(DashboardRenderer): MID = self.W // 2 # 400 y_l = self._usage_block(10, MID - 14, DIV_Y + 6, usage) y_r = self._usage_block(MID + 14, R, DIV_Y + 6, gpt) if gpt else y_l - self.vdash(MID, DIV_Y + 6, max(y_l, y_r)) - DIV2_Y = max(y_l, y_r) + 6 + self.vdash(MID, DIV_Y + 6, max(y_l, y_r) + 6) + DIV2_Y = max(y_l, y_r) + 16 # 分割线离末条进度条 16px,避免拥挤 self.hdash(8, self.W - 8, DIV2_Y) # ---- C. 橘喵·今日实时(全宽三列)---- @@ -188,8 +228,9 @@ class E1002Renderer(DashboardRenderer): DIV3_Y = end_c + 8 self.hdash(8, self.W - 8, DIV3_Y) - # ---- D. 橘喵·近7天 数据表 ---- - self._week_table(DIV3_Y + 6, mao.get("week")) + # ---- D. BTC/USDT 日 K ---- + strip = config.E1002_COLOR_STRIP + self._btc_chart(DIV3_Y + 6, self.H - (16 if strip else 10), data.get("btc")) # ---- C. 可选:六色测试色条 ---- if config.E1002_COLOR_STRIP: diff --git a/server.py b/server.py new file mode 100644 index 0000000..a1a9802 --- /dev/null +++ b/server.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +图片/网页服务 —— 给 SenseCraft HMI 的 HTML 控件周期抓取的**恒定 URL**,每次请求都**现取数据、现渲染**,全程内存、不落盘。 + +真机验证结论(2026-09):SenseCraft 的 Image 控件只在填写 URL 时抓一次快照、此后不再更新; +HTML 控件填同一个 URL 则**每个刷新周期都重抓**。所以对外只暴露 HTML 控件用的 URL,两种形态并行供对比: + 方案一 GET (默认 /e1002.png)→ image/png:与 main.py --target e1002 同一套 Pillow 点阵渲染(renderer_e1002) + 方案二 GET (默认 /e1002.html)→ text/html:800x480 的现代网页(html_renderer,系统/网络字体 + 内联 SVG K 线), + 由平台无头浏览器截图后量化(真机实拍:文字抗锯齿灰边被平台抖动成毛边、小字糊) + 方案三 GET (默认 /e1002-web.png)→ image/png:同一网页由**服务端**无头 Chrome 截图,再按最近色(不抖动) + 固化为六色 PNG(html_shot),平台拿到的已是纯色图;加 ?raw=1 返回未量化的原始截图供对照。需服务器装 Chrome。 +两者共用同一份 build_data(): + - 日期时间:服务器按 config.TZ_NAME 时区现算 + - 天气 / 橘喵今日实时 / BTC K 线:**内存缓存 + 有效期**(WEATHER_TTL / MAO_TTL / BTC_TTL,.env 可配,0=每次实拉): + 收到请求先查该路缓存是否过期,过期才实拉并更新时间戳,否则直接用缓存渲染;拉取失败沿用旧值、不更新时间戳(下次再试), + 从未成功则占位 "--"。整个服务**不读本机任何文件**(部署在公网服务器)。 + - Claude Usage / ChatGPT Usage:**不主动拉取**,由本机 `main.py --target push` 推送原始快照到 POST /push/usage、/push/usage_gpt, + 服务端暂存最新一份(内存;PUSH_STATE_PATH 非空时同时落盘,重启恢复);渲染时按当前时刻现算 pace, + 所以推送时机与设备抓取时机不必对齐。未推送过:Claude 显示 "--",ChatGPT 用 mock 并标「示例数据」。 + - 推送鉴权:请求头 X-Push-Token 必须等于 PUSH_TOKEN;PUSH_TOKEN 为空时拒绝一切推送(服务在公网,务必配置)。 +其他端点:GET /health → JSON 运行状态;GET /push/usage、/push/usage_gpt → 查看暂存的原始快照(需同样令牌)。 +部署:python3 server.py(监听 SERVER_HOST:SERVER_PORT),前置 Caddy/Nginx 做 HTTPS,或 cloudflared tunnel。 +""" +import io +import json +import os +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import config +import data +from renderer_e1002 import E1002Renderer +import html_renderer +import html_shot + + +class State: + """服务端内存状态:推送来的原始用量快照 + 上游数据的「上一次成功值」+ 计数。""" + + def __init__(self): + self.lock = threading.Lock() + self.raw = {"usage": None, "usage_gpt": None} + self.pushed_at = {"usage": None, "usage_gpt": None} + self.last_good = {"weather": None, "mao": None, "btc": None} # 各路最近一次成功的数据 + self.fetched_at = {"weather": 0.0, "mao": 0.0, "btc": 0.0} # 各路最近一次成功拉取的时间戳(缓存有效期起点) + self.renders = {"png": 0, "html": 0} + self.last_render = None + self.started = time.time() + self._load() + + # ---- 推送数据持久化(可选)---- + def _load(self): + p = config.PUSH_STATE_PATH + if not p or not os.path.exists(p): + return + try: + with open(p, encoding="utf-8") as f: + st = json.load(f) + self.raw.update(st.get("raw") or {}) + self.pushed_at.update(st.get("pushed_at") or {}) + print(f"[状态] 已从 {p} 恢复推送数据") + except Exception as e: + print(f"[警告] 推送状态恢复失败:{e}") + + def _save(self): + p = config.PUSH_STATE_PATH + if not p: + return + try: + tmp = p + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump({"raw": self.raw, "pushed_at": self.pushed_at}, f, ensure_ascii=False) + os.replace(tmp, p) + except Exception as e: + print(f"[警告] 推送状态落盘失败:{e}") + + def push(self, kind, raw): + with self.lock: + self.raw[kind] = raw + self.pushed_at[kind] = time.time() + self._save() + + +STATE = State() +_RENDER_LOCK = threading.Lock() # Pillow 渲染器持有 self.img/self.d,非线程安全 +_PNG_RENDERER = E1002Renderer() + + +def _cached(key, ttl, fetch, fallback): + """带有效期的内存缓存:未过期 → 直接返回缓存;过期 → 实拉,成功则更新数据与时间戳,失败沿用旧值(无旧值用 fallback)。 + fetch() 返回 (data, ok)。返回 (data, 状态字串) 供日志。""" + now = time.time() + with STATE.lock: + cached, ts = STATE.last_good[key], STATE.fetched_at[key] + if cached is not None and ttl > 0 and now - ts < ttl: + return cached, f"{key}:cache({int(now - ts)}s)" + try: + d, ok = fetch() + except Exception as e: # noqa: BLE001 —— 上游任何异常都按失败处理 + print(f"[警告] {key} 拉取异常:{e}") + d, ok = None, False + if ok: + with STATE.lock: + STATE.last_good[key], STATE.fetched_at[key] = d, now + return d, f"{key}:fetched" + if cached is not None: + return cached, f"{key}:stale" + return fallback, f"{key}:none" + + +def build_data(now=None): + """组装渲染数据:三路上游按各自 TTL 走内存缓存,用量取推送暂存。返回 (data, 各路状态列表)。""" + now = now or data.now_tz() + with STATE.lock: + raw_usage, raw_gpt = STATE.raw["usage"], STATE.raw["usage_gpt"] + + weather, s1 = _cached("weather", config.WEATHER_TTL, data.weather_fetch, + {"loc": config.WEATHER_LOC_NAME, "cond": "--", "temp": "--", "range": "--", "icon": ""}) + mao, s2 = _cached("mao", config.MAO_TTL, lambda: data.mao_fetch(now, with_trend=False), + {"upd": now.strftime("%H:%M"), "cols": [data._placeholder_col(k) for k in ("单量", "流水", "毛利")], + "trend": {"dates": [], "series": []}}) + btc, s3 = _cached("btc", config.BTC_TTL, lambda: (data.btc_fetch(), True), data.btc_empty()) + + d = { + "date": {"greg": now.strftime("%Y/%m/%d"), "week": data.WEEK_CN[now.weekday()], "time": now.strftime("%H:%M")}, + "weather": weather, + "mao": mao, + "usage": data._usage(now, raw=raw_usage if raw_usage is not None else {}), # 未推送 → 全部 "--"(不读本机文件) + "usage_gpt": data._usage_gpt(now, raw=raw_gpt), # 未推送 → mock(标「示例数据」) + "btc": btc, + } + return d, [s1, s2, s3] + + +def render_png_bytes(d): + """渲染 PNG 到内存字节。""" + with _RENDER_LOCK: + img = _PNG_RENDERER.render(d) + buf = io.BytesIO() + img.save(buf, "PNG", optimize=True) + return buf.getvalue() + + +class Handler(BaseHTTPRequestHandler): + server_version = "eink-push/1.0" + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): + pass + + def _log(self, code, note=""): + ua = self.headers.get("User-Agent", "-") + ip = self.headers.get("CF-Connecting-IP") or self.headers.get("X-Forwarded-For") or self.client_address[0] + print(f"[{time.strftime('%H:%M:%S')}] {code} {self.command} {self.path} ip={ip} ua={ua[:60]} {note}", flush=True) + + def _send(self, code, ctype, body, extra=None): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + self.send_header("Pragma", "no-cache") + self.send_header("Expires", "0") + for k, v in (extra or {}).items(): + self.send_header(k, v) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def _json(self, code, obj): + self._send(code, "application/json; charset=utf-8", json.dumps(obj, ensure_ascii=False).encode("utf-8")) + + def _authed(self): + tok = config.PUSH_TOKEN + return bool(tok) and self.headers.get("X-Push-Token", "") == tok + + # ---------- GET ---------- + def do_GET(self): + path = self.path.split("?", 1)[0] + try: + if path == config.IMAGE_PATH: + t0 = time.time() + d, st = build_data() + body = render_png_bytes(d) + with STATE.lock: + STATE.renders["png"] += 1 + STATE.last_render = time.time() + self._log(200, f"png {len(body)}B {time.time() - t0:.2f}s [{' '.join(st)}]") + return self._send(200, "image/png", body) + if path == config.HTML_PATH: + t0 = time.time() + d, st = build_data() + body = html_renderer.render_html(d).encode("utf-8") + with STATE.lock: + STATE.renders["html"] += 1 + STATE.last_render = time.time() + self._log(200, f"html {len(body)}B {time.time() - t0:.2f}s [{' '.join(st)}]") + return self._send(200, "text/html; charset=utf-8", body) + if path == config.HTML_PNG_PATH: + t0 = time.time() + raw = "raw=1" in (self.path.split("?", 1) + [""])[1] + d, st = build_data() + html = html_renderer.render_html(d) + img = html_shot.screenshot_html(html) + if not raw: + img = html_shot.quantize6(img) + buf = io.BytesIO(); img.save(buf, "PNG", optimize=True); body = buf.getvalue() + with STATE.lock: + STATE.renders["web_png"] = STATE.renders.get("web_png", 0) + 1 + STATE.last_render = time.time() + self._log(200, f"web-png{'(raw)' if raw else ''} {len(body)}B {time.time() - t0:.2f}s [{' '.join(st)}]") + return self._send(200, "image/png", body) + if path == "/health": + with STATE.lock: + st = {"ok": True, "uptime_s": int(time.time() - STATE.started), "renders": dict(STATE.renders), + "last_render": STATE.last_render, "pushed_at": dict(STATE.pushed_at), + "has_last_good": {k: v is not None for k, v in STATE.last_good.items()}, + "cache_age_s": {k: (int(time.time() - v) if v else None) for k, v in STATE.fetched_at.items()}, + "ttl_s": {"weather": config.WEATHER_TTL, "mao": config.MAO_TTL, "btc": config.BTC_TTL}, + "image_path": config.IMAGE_PATH, "html_path": config.HTML_PATH, + "html_png_path": config.HTML_PNG_PATH, "tz": config.TZ_NAME} + self._log(200) + return self._json(200, st) + if path in ("/push/usage", "/push/usage_gpt"): + if not self._authed(): + self._log(401); return self._json(401, {"ok": False, "error": "bad token"}) + kind = path.rsplit("/", 1)[1] + with STATE.lock: + raw, at = STATE.raw[kind], STATE.pushed_at[kind] + self._log(200) + return self._json(200, {"ok": True, "kind": kind, "pushed_at": at, "raw": raw}) + if path == "/favicon.ico": + return self._send(204, "image/x-icon", b"") + self._log(404) + self._json(404, {"ok": False, "error": "not found"}) + except Exception as e: # noqa: BLE001 —— 任何渲染异常都返回 500 而不是断开连接 + self._log(500, repr(e)) + self._json(500, {"ok": False, "error": repr(e)}) + + do_HEAD = do_GET + + # ---------- POST:推送 ---------- + def do_POST(self): + path = self.path.split("?", 1)[0] + if path not in ("/push/usage", "/push/usage_gpt"): + self._log(404); return self._json(404, {"ok": False, "error": "not found"}) + if not self._authed(): + self._log(401); return self._json(401, {"ok": False, "error": "bad token"}) + try: + n = int(self.headers.get("Content-Length") or 0) + if n <= 0 or n > 64 * 1024: + raise ValueError(f"bad content-length {n}") + raw = json.loads(self.rfile.read(n).decode("utf-8")) + if not isinstance(raw, dict): + raise ValueError("body must be a JSON object") + except Exception as e: # noqa: BLE001 + self._log(400, repr(e)); return self._json(400, {"ok": False, "error": f"bad json: {e}"}) + kind = path.rsplit("/", 1)[1] + STATE.push(kind, raw) + self._log(200, f"push {kind} keys={sorted(raw)[:6]}") + self._json(200, {"ok": True, "kind": kind, "received_at": time.time()}) + + +def main(): + if not config.PUSH_TOKEN: + print("[警告] PUSH_TOKEN 未配置:将拒绝一切推送,用量区会一直显示 \"--\"") + srv = ThreadingHTTPServer((config.SERVER_HOST, config.SERVER_PORT), Handler) + print(f"服务已启动 http://{config.SERVER_HOST}:{config.SERVER_PORT} 方案一 {config.IMAGE_PATH} 方案二 {config.HTML_PATH} " + f"方案三 {config.HTML_PNG_PATH} /health", flush=True) + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main()